[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 reported500 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
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
{ "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
- RELEASED
- PREPARED or ERRORED
- FINALIZED
Go to step 3. This is the case finalize exists for.
Step 3. Finalize
- 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_FINALIZEDand builds nothing. Running it twice, or twice at once, produces one record. - Success returns
200withoutcome: FINALIZEDand 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, usingbusinessTransactionId, action and target from the intent. Then record what you found, and a note saying what you checked.
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.
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
- With the API (no credential)
- Offline (no network, no database)
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
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.
intent field of GET /execution-intents/<id> as a plain dictionary and the public key as text, and needs no network and no database.
Turn it on: the deployment order matters
Execution Intents are enforced by default. In production, and wheneverNODE_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
supabase/migrations/20260921120000_add_execution_intents.sqlto the production database. It only adds a table and is safe to run twice. -
Confirm the table exists.
The output must be
execution_intents. Empty output means the migration did not apply. - Deploy.
-
Check
GET /ready. It returnsREADY. If the table is missing it returns503withstatus: NOT_READYand areasonnaming the migration file, so a skipped migration is caught at the readiness check and not on the first real request. -
Check the startup log for
executionIntentsConfigured: true.
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
409and 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 keyalias/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.