Skip to main content
[AVAILABLE] since 2026-09-21. Design: docs/adr/ADR-0012-Signed-Execution-Intent-Before-Release.md. Code: packages/runtime/src/ExecutionIntentService.ts, ExecutionIntentFinalizer.ts, packages/crypto/src/ExecutionIntentCrypto.ts. Verified live on 2026-09-21, see What was verified.

The problem this solves

An Execution Trust Record contains the result of the execution, so it can only be built after the action has been released to the connector. Before this feature, that left a window: if the record could not be produced or stored after release, an action had run and nothing signed described it. The runtime reported 500 EXECUTION_RECORD_INCOMPLETE and logged the identifiers, but the context needed to rebuild the record existed only in the memory of the failed request. An Execution Intent closes that window. Before the connector is called, the runtime signs and stores a record of what it is about to release. If that cannot be done, nothing is released.

What happens to a request, in order

1

Accept

The request is validated and recorded as received.
2

Decide and authorize

Policy is evaluated. If it approves, the runtime signs an execution authorization. A policy refusal ends here with 403, and nothing is released.
3

Signing readiness check

The runtime proves the signing path works (503 SIGNING_UNAVAILABLE if not). Nothing is released.
4

Sign and store the Execution Intent

The runtime signs the intent and writes it to the database. If this fails, the caller gets 503 EXECUTION_INTENT_UNAVAILABLE and nothing is released.
5

Release

The action is released to the connector.
6

Save the execution context

Right after the connector answers, the execution context is saved on the intent, so the Trust Record can be rebuilt later. This step is best effort: if it fails, it is logged at critical severity and the request carries on.
7

Build and store the Trust Record

The signed Execution Trust Record is built and stored. If this fails, the caller gets 500 EXECUTION_RECORD_INCOMPLETE.
8

Mark the intent FINALIZED

The intent is marked FINALIZED with the Trust Record id, and the saved context is deleted. This step is best effort.
9

Verify and issue the receipt

As before.

What is signed

The intent contains only facts that exist before release. It never contains the execution result, and never contains the raw intent parameters. A real captured intent is in Get an Execution Intent.

What an intent proves, and what it does not

The honest reading of an intent with no Trust Record is: the action may or may not have run, and someone has to check the connector.

The five states

The state is operational status kept next to the signed intent. It is not signed, so it changes as the request progresses. FINALIZED also records finalizationMode: INLINE when the Trust Record was produced in the original request, REPAIRED when it was rebuilt afterwards by finalize. RESOLVED also records resolution (NOT_EXECUTED or EXECUTED, what the operator found at the connector), resolutionNote, resolvedBy and resolvedAt. That is an attributed, timestamped statement by a person, stored in the unsigned status. It is not tamper evident, and it is not a signed Trust Record. For an action that ran, the signed evidence is still the intent, and the operator’s note is the trail.

Every failure, and what the caller sees

On EXECUTION_RECORD_INCOMPLETE the action was released. Do not resubmit it under a new businessTransactionId. That would perform the action twice.

Find and repair a released action with no signed record

These routes need a credential provisioned as a verified human (credentialHolderType: USER). Any other credential gets 403 NON_HUMAN_CALLER_DENIED. Set two variables first.

Step 1. List what needs attention

The response is { "intents": [ { "intent": {...}, "status": {...} } ] }, oldest first, at most 50 by default (?limit= up to 200). Every entry is an action that may have been released with no signed record, or whose outcome nobody has reconciled yet. A RESOLVED intent no longer appears.

Step 2. Decide by state

Go to step 3. This is the case finalize exists for.

Step 3. Finalize

What finalize does, and does not do:
  • It never calls the connector. It reads the execution context saved right after release and runs the same record building step the runtime uses.
  • It also verifies the record and generates its receipt, so the repaired record ends in the same state as an ordinary one.
  • It is idempotent. If a Trust Record already exists it returns it with outcome: ALREADY_FINALIZED and builds nothing. Running it twice, or twice at once, produces one record.
  • Success returns 200 with outcome: FINALIZED and the Trust Record.

Step 4. Close an intent you reconciled at the connector (PREPARED or ERRORED)

Do this only after you have checked the connector, using businessTransactionId, action and target from the intent. Then record what you found, and a note saying what you checked.
The note is required, at most 2000 characters, and is the only record of what you found. resolvedBy is taken from your credential, and resolvedAt is set by the server.
A resolution is an attributed statement by an operator, stored in the intent’s unsigned status. It is not tamper evident, and it does not create a Trust Record. If you resolve an intent as EXECUTED, no signed Trust Record exists for an action that ran, so record the outcome in your own systems. The signed intent, your note and the connector’s own record are the evidence. The server writes a log line execution_intent_resolved with the transaction and the caller each time.
Resolving never calls a connector.

Step 5. Confirm a repair

status.state should now be FINALIZED and status.finalizationMode should be REPAIRED. The rebuilt record is then returned by GET /trust-records/<businessTransactionId> like any other. A repaired record has its own createdAt, the time of the repair. The times of the execution itself are inside the record’s execution evidence. Compare status.releasedAt with the record’s createdAt to see how long the gap was.

Verify an intent

POST /execution-intents/verify takes the intent itself and returns { "valid": true } or { "valid": false }. It needs no API key, reads no storage, and is documented at Verify an Execution Intent.
The Python SDK has an offline verifier, verify_execution_intent_offline. The TypeScript SDK has no offline verifier, so use the API route or the script above. See Use the SDKs.

Use the SDKs

These methods are in the SDK source, and are not in the published 1.1.6. They ship in the next SDK release. Until then, use the HTTP API.
A non human credential gets an AuthorizationError. Resolving an intent that cannot be closed gets a ConflictError. A missing intent gets a NotFoundError. 503 EXECUTION_INTENT_UNAVAILABLE arrives on execute as an InternalServerError whose server code is EXECUTION_INTENT_UNAVAILABLE, and nothing was executed.
To close an intent after you checked the connector, pass what you found and a note. The note is required.
The Python offline verifier takes the intent field of GET /execution-intents/<id> as a plain dictionary and the public key as text, and needs no network and no database.
It agrees with the server: a real server signed intent, and intents signed by the TypeScript signer including non ASCII text, all verify in Python.

Turn it on: the deployment order matters

Execution Intents are enforced by default. In production, and whenever NODE_ENV is not exactly test or development, there is no switch to turn them off. See EXECUTION_INTENTS_CHECK in the environment variable reference.
Apply the database migration before you deploy this version. With the new code and no execution_intents table, every execution is refused with 503 EXECUTION_INTENT_UNAVAILABLE.
  1. Apply supabase/migrations/20260921120000_add_execution_intents.sql to the production database. It only adds a table and is safe to run twice.
  2. Confirm the table exists.
    The output must be execution_intents. Empty output means the migration did not apply.
  3. Deploy.
  4. Check GET /ready. It returns READY. If the table is missing it returns 503 with status: NOT_READY and a reason naming the migration file, so a skipped migration is caught at the readiness check and not on the first real request.
  5. Check the startup log for executionIntentsConfigured: true.
The migration changes no existing table. Rolling the code back leaves the new table in place, and nothing reads it.

Cost

One more signing operation and one more database write happen before every release, and two more writes after it.

Limits, stated plainly

  • An intent proves what was about to be released. It does not prove release or the result.
  • Finalize can only rebuild a record when the execution context was saved. When that save fails, finalize refuses with 409 and the outcome must be established from the connector.
  • Transactions created before this feature have no intent. Their behavior is unchanged.
  • Closing an intent (resolve) records an operator’s statement in unsigned status. It is not tamper evident and it does not create a Trust Record.
  • The list route returns intents across all callers, so it needs a verified human credential.
  • The SDK methods for the intent routes are in the SDK source and are not in the published 1.1.6. They ship in the next SDK release. The TypeScript SDK has no offline intent verifier.

What was verified

On 2026-09-21, against the real stack: the production Docker image built from this code, a real Postgres with every migration applied, and the real AWS KMS key alias/default in ap-south-1 (ECC_NIST_EDWARDS25519), using the limited IAM user parmana-kms-operator. The full refund chain ran through the real parmana-paytm-agent with fake Paytm staging credentials, because what was under test is the intent lifecycle and not Paytm. 23 of 23 checks passed: A separate repeat run produced an intent in state ERRORED with the reason PaytmConnector "paytm" request to capability "paytm:refund" timed out after 10000ms. That was a real connector timeout on a slow Paytm staging call, and it is the designed behavior: the release stage raised an error, so the intent recorded that the outcome is unknown and did not claim nothing happened. Closing an intent by hand (resolve) was verified later the same day, first with local signing keys and then under real AWS KMS. With the same production image and a real Postgres, the connector was made unreachable, so the release raised a real error (fetch failed) and left the intent in ERRORED. Finalize refused it with 409. A resolve with no note was refused with 400, and a non human credential with 403. A verified human then closed it: RESOLVED, NOT_EXECUTED, the note and the author recorded, and the intent left the unfinalized list. A second resolve changed nothing (ALREADY_RESOLVED), and resolving a FINALIZED intent was refused with 409. 35 of 35 checks passed with local keys, and the final run under KMS, which has one more check, passed 37 of 37. An earlier attempt under KMS had one scenario fail because the agent’s own call to Paytm staging failed on the network (fetch failed): the intent correctly ended ERRORED and the scenario never reached the state it tests. The check no longer depends on the internet: the rig now answers the agent’s one call to Paytm staging with a local stand in (the response shape real staging returned for a bad merchant id), and it passed 37 of 37 three times in a row under KMS. The 23 of 23 run above called real Paytm staging with fake credentials. A later run also failed once because the temporary AWS credentials handed to the container had expired after about 15 minutes, so the rig now records their expiry and stops early with an instruction. The resolve SQL was also run against a real Postgres (12 checks, including both database constraints). Not verified: behavior from Vercel with the OIDC role, latency from Vercel, or a real Paytm refund. The automated tests cover the lifecycle at unit, storage and HTTP level, and the storage queries were also run against a real Postgres.