Skip to main content
[AVAILABLE], and wired into the default server as of commit 651497a (2026-07-11). Earlier revisions of this page said the opposite, see the correction below.
This page previously said the default local server does NOT enforce content binding. That was true through commit 4740aee (2026-07-08). As of 651497a, packages/api/src/bootstrap/createExecutionSystem.ts unconditionally builds a real ExecutionGateway, so every POST /execute against the stock server does get the hash recompute-and-compare described below. See The gateway for the full, current mechanism and the one caveat that remains (only one connector is registered).

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:
// packages/shared/src/domain/execution-authorization.ts
export interface ExecutionAuthorizationPayload {
  readonly version: 1;
  readonly authorizationId: string;
  readonly nonce: string;
  readonly decisionId: string;
  readonly businessTransactionId: string;
  readonly policyName: string;
  readonly policyVersion: string;
  readonly authorizedAt: string;
  readonly expiresAt: string;

  /**
   * Canonical content hash of the ExecutableContent (businessTransactionId,
   * action, target, parameters) approved for execution. Computed identically
   * by the signing side and the verifying side via the same
   * ExecutableContentHasher, so a receiving gateway can recompute this hash
   * from the exact content it is about to forward and reject any mismatch.
   */
  readonly businessTransactionHash: string;
}
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:
// packages/execution-gateway/src/ExecutionGateway.ts:150-165
if (passed) {
  const actualHash = await this.contentHasher.hash(executableContent);
  const expectedHash = request.authorization.payload.businessTransactionHash;

  businessTransactionHashMatches = actualHash === expectedHash;

  if (!businessTransactionHashMatches) {
    hashMismatch = { expected: expectedHash, actual: actualHash };
  }
}
Verification order, side-effect-free checks first (ExecutionGateway.ts:86-90):
version → signature → expiry → TTL policy → businessTransactionHash recompute-and-compare → nonce
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:170-180). 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.

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.”
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.

Confirmed: the default local server now enforces this

This was traced directly, not assumed. packages/api/src/bootstrap/createExecutionSystem.ts unconditionally returns createExecutionGateway() (commit 651497a, 2026-07-11), 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: only one connector is registered by default, an action with no matching 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:
// examples/tutorials/36-parameter-tampering/run.ts
const originalHash = context.authorization.payload.businessTransactionHash;
const tampered = { ...content, parameters: { ...content.parameters, paymentAmount: 500000 } };
const tamperedHash = await hasher.hash(tampered);
// originalHash !== tamperedHash
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

@parmana/connector-sdk’s SdkConnectorExecutor 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’s 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. connector-sdk adds capability-declaration and version/health checks in front of a connector call (SdkConnectorExecutor, CapabilityConnectorPolicy), all of which fail closed (throw, never a partial or guessed success) before a connector is ever invoked.
  • Nonce single-use is scoped to whichever NonceStore instance checks it. Multiple independent gateway instances each using their own MemoryNonceStore can each accept the same authorization once. Fleet-wide single-use requires one shared store (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 specifically so the exposure window from either of the above gaps is bounded, not unlimited.