Skip to main content
[AVAILABLE] since 2026-09-21. Design:. 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, and on EXECUTION_OUTCOME_UNKNOWN it may have been. In both cases do not resubmit it under a new businessTransactionId. That could 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.
Both SDKs have an offline verifier: verify_execution_intent_offline in Python, and verifyExecutionIntentOffline in TypeScript from 1.3.0 (published 2026-09-25). On TypeScript 1.2.0 and earlier, use the API route or the script above. See Use the SDKs and SDK coverage of the API.

Use the SDKs

These methods are available from SDK 1.2.0 (published 2026-09-21). On 1.1.6 and earlier, 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 available from SDK 1.2.0. Earlier versions do not have them. The TypeScript SDK has an offline intent verifier from 1.3.0, published 2026-09-25; 1.2.0 has none.

What was verified

The intent lifecycle is covered by automated tests at unit, storage and HTTP level, and the storage queries were run against a real Postgres. A dated live check, run against a production build with a real KMS key, is recorded in the claims changelog. Its connector and staging details are not repeated here.