# Building Transactional Actions

Build an action with GraphQL calls, conditional execution, atomic writes, and safe reruns. Observe rollback, choose retry boundaries deliberately, and call the action as a tool.

An action can run a sequence of GraphQL operations in response to a committed change. In this tutorial, an account update requests a dedicated journal. The action checks whether that journal exists, creates it if needed, and reads it back before committing. You will deliberately fail that sequence to observe rollback, then complete it and rerun it without creating another journal.

This is a provisioning example, not a funds movement: creating a journal does not post entries or move balances.

> **Note:**
>
> Start with [Running Actions Locally](https://www.twisp.com/docs/tutorials/actions.md). Run each operation separately in GraphiQL. Use the local Twisp tenant header `X-Twisp-Account-Id: 000000000000`. In another environment, use that tenant's header and credentials. Actions always execute in the tenant where they are defined.

Use fresh UUIDs and a fresh account code if you repeat the tutorial in the same database. The two UUIDs below identify the ledger account and the action. We also use the ledger account's UUID as its dedicated journal's UUID: they are different entity types, and this gives every delivery a stable destination ID.

## 1. Create the source account

```graphql
mutation CreateProvisioningAccount {
  createAccount(
    input: {
      accountId: "70eec1a6-eac1-47aa-a507-e18f2d486425"
      code: "ADVANCED-ACTIONS"
      name: "Advanced actions account"
      description: "demo:idle"
      normalBalanceType: DEBIT
    }
  ) {
    accountId
    code
  }
}
```

The account already exists when you publish the action. Its creation will not be replayed. We will trigger the action with an update after publication.

## 2. Create a Starlark action

The trigger requires an update to this account, with a description of `demo:rollback` or `demo:provision`. The description is a convenient command marker for this exercise. A production application could use a dedicated record or metadata field instead.

The trigger reads **record fields**, so it uses `account_id` and a typed `uuid(...)` literal. GraphQL queries use schema fields such as `accountId` and `journalId`. Keep these two naming conventions separate.

```graphql
mutation CreateProvisioningAction {
  actions {
    createAction(
      id: "1ac86b64-1d65-4232-8556-e8063f849b04"
      input: {
        name: "provision_account_journal"
        description: "Provision one journal per account"
        kind: "tool"
        tool: {
          name: "provision_account_journal"
          description: "Create the dedicated journal for an account if it does not exist."
          inputSchema: { type: "object" }
          outputSchema: { type: "object" }
        }
        triggers: [
          {
            event: """
            event.type == "account.updated" &&
            event.data.account_id == uuid("70eec1a6-eac1-47aa-a507-e18f2d486425") &&
            event.data.description in ["demo:rollback", "demo:provision"]
            """
          }
        ]
        policies: [{ effect: ALLOW, actions: [SELECT, INSERT], resources: ["financial.Journal.*"] }]
        execution: { timeout: "5m", responseTimeout: "30s", maxInvocations: 1 }
        config: {
          runtime: "starlark"
          src: """
          # Transactional provisioning for the advanced actions tutorial.
          # Journal identity is stable across action retries and manual reruns.
          def run(event):
              account = event["data"]
              variables = {"id": account["account_id"]}
              found = graphql(
                  "query Find($id: UUID!) { journal(id: $id) { journalId code name } }",
                  variables,
                  operation_name="Find",
              )
              if found["journal"] != None:
                  return {"outcome": "already-provisioned", "journal": found["journal"]}

              graphql(
                  "mutation Create($id: UUID!, $code: String!) { createJournal(input: {journalId: $id, code: $code, name: \"Action-provisioned journal\"}) { journalId } }",
                  {"id": account["account_id"], "code": account["code"]},
                  operation_name="Create",
              )
              verified = graphql(
                  "query Read($id: UUID!) { journal(id: $id) { journalId code name } }",
                  variables,
                  operation_name="Read",
              )
              if account["description"] == "demo:rollback":
                  fail("Deliberately fail after creating and reading the journal")
              return {"outcome": "provisioned", "journal": verified["journal"]}
          """
        }
      }
    ) {
      actionId
      principal
      version
      status
      pending
    }
  }
}
```

Actions that run programs have kind `tool`: the `tool` block describes the
interface, and the config holds the `runtime` and its program in `src`. The
program defines `run(event)` and calls `graphql()` with a query, variables,
and an optional operation name. The returned value becomes `invocation.output`.

## 3. Wait for publication

```graphql
query ProvisioningAction {
  actions {
    action(id: "1ac86b64-1d65-4232-8556-e8063f849b04") {
      version
      status
      pending
      published
      principal
    }
  }
}
```

Wait for `status: ACTIVE` and `pending: null`. Publication is asynchronous and advances the record version. A successful create response alone does not mean the action is matching events yet.

## 4. Observe an atomic rollback

```graphql
mutation RequestRollback {
  updateAccount(
    id: "70eec1a6-eac1-47aa-a507-e18f2d486425"
    input: { description: "demo:rollback" }
  ) {
    accountId
    description
    version
  }
}
```

The action finds no journal, creates one, reads its uncommitted write, then calls `fail()`. Poll the following query until the execution is terminal:

```graphql
query InspectProvisioning {
  journal(id: "70eec1a6-eac1-47aa-a507-e18f2d486425") {
    journalId
    code
    name
  }
  actions {
    executions(where: { actionId: "1ac86b64-1d65-4232-8556-e8063f849b04" }, sort: DESC, first: 10) {
      nodes {
        executionId
        actionVersion
        status
        overdue
        deadline
        nextInvocationAt
        event {
          eventId
          eventType
          recordVersion
          data
          previous
        }
        invocation {
          number
          responseDeadline
          output
          error {
            code
            message
            retryable
          }
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}
```

Expect the journal to remain `null`, the execution to end `FAILED`, and the invocation to report `action_failure` with no output. `fail()` reports an error that retrying the same event cannot fix, so the execution is not retried.

The account update remains committed: it happened before the action started. Inside the action, the journal write and successful action outcome would have committed together. Instead, the failed transaction rolls back its business writes and the failure is recorded in a separate transaction. The failure remains inspectable even though the journal never became visible.

## 5. Provision successfully

```graphql
mutation RequestProvisioning {
  updateAccount(
    id: "70eec1a6-eac1-47aa-a507-e18f2d486425"
    input: { description: "demo:provision" }
  ) {
    accountId
    description
    version
  }
}
```

Run `InspectProvisioning` again. This update creates a **new event and execution**. The new execution reaches `SUCCEEDED`, the journal exists, and `invocation.output` contains:

```json
{
  "outcome": "provisioned",
  "journal": {
    "journalId": "70eec1a6-eac1-47aa-a507-e18f2d486425",
    "code": "ADVANCED-ACTIONS",
    "name": "Action-provisioned journal"
  }
}
```

The second query sees the journal before the action commits. That is useful when later calls must validate or use earlier writes in the same invocation.

## 6. Rerun without duplicating the effect

Copy the **successful** execution ID into the Variables panel:

```json
{ "executionId": "REPLACE_WITH_SUCCESSFUL_EXECUTION_UUID" }
```

```graphql
mutation RerunProvisioning($executionId: UUID!) {
  actions {
    rerunExecution(
      id: $executionId
      input: { executionId: "d3d616a1-17e1-47fd-91d9-1a66ceefecba" }
    ) {
      executionId
      status
      rerunOf {
        executionId
      }
    }
  }
}
```

The rerun gets a new execution ID but receives the same event. It reads the existing journal and finishes with `outcome: "already-provisioned"`. It does not attempt another insert. Repeating this exact mutation returns the same rerun; choose another `input.executionId` to request a separate one.

The stable journal ID and existence check make this operation safe to repeat after completion. They do not serialize concurrent executions: two invocations may both initially see no journal. Uniqueness constraints and transaction conflict handling remain necessary, and a retry must start with the lookup again. In production, also check that an existing journal has the expected ownership and configuration rather than assuming any record at that ID is acceptable.

A rerun pins the source execution's action version by default. Updating the action does not silently upgrade old executions or reruns. To test a corrected definition against an old event, publish it, read its exact version, and pass that integer as `input.actionVersion` alongside a fresh rerun ID. Changing the source account later does not change the event already pinned on an execution. Action authorization still uses the action client's current policies.

## 7. Choose the transaction and retry boundaries

Starlark actions always execute GraphQL calls in one transaction. Later calls can read earlier writes. A script or GraphQL error rolls back all database writes from the invocation; partial GraphQL response data is not returned. Retrying starts the program in a fresh transaction. There is no config option to change this behavior.

Action retry settings apply to the whole program. Retryable failures restart it with the pinned event and action version. `responseTimeout` bounds the synchronous invocation; `timeout` also includes queue time and retry delays. A Starlark action has no durable waits or external completion ID.

Do not put `@tx`, `@dryRun`, or `@retry` inside transactional `graphql()` calls: the action owns the transaction boundary. Atomicity covers writes in that database transaction. External effects and independently committed operations are outside that boundary.

## 8. Inspect failures and stop future work

Read the execution's retained history to distinguish record versions from invocation numbers:

```graphql
query ProvisioningHistory($executionId: UUID!) {
  actions {
    execution(id: $executionId) {
      status
      actionVersion
      history(first: 20) {
        nodes {
          version
          status
          invocation {
            number
            startedAt
            error {
              code
              message
              retryable
            }
          }
        }
        pageInfo {
          endCursor
          hasNextPage
        }
      }
    }
  }
}
```

History is newest first. `invocation.number` counts invocations; `version` tracks changes to the execution record. `overdue: true` is an observation that a deadline has passed, not proof that the worker stopped at that instant.

To stop an execution, call `actions.cancelExecution(id: ...)`. A `QUEUED` or `RETRY_SCHEDULED` execution is `CANCELLED` at once. A `RUNNING` one may be returned still `RUNNING` with its cancellation requested: its invocation's writes roll back, and it becomes `CANCELLED` when the invocation ends. Read it again to see the result.

Disable this tutorial's action when you finish:

```graphql
mutation DisableProvisioning {
  actions {
    updateAction(id: "1ac86b64-1d65-4232-8556-e8063f849b04", input: { enabled: false }) {
      status
      pending
    }
  }
}
```

Wait for `DISABLED` with `pending: null`. Disabling stops future matching after publication; it does not cancel existing executions or undo the provisioned journal.

## Use CEL for typed calculations

Events retain their typed UUIDs, integers, money, decimals, and other domain values inside Starlark. `cel(expression, bindings)` exposes the shared CEL registry for domain arithmetic and comparisons:

```python
def run(event):
    amount = cel('money("12.50", "USD")')
    total = cel('amount + money("2.50", "USD")', {"amount": amount})
    return {"total": total, "account_id": event["data"]["account_id"]}
```

Pass bindings explicitly. Typed values returned by CEL can be passed to another CEL call or returned as action output without converting through JSON.

GraphQL remains a JSON boundary. Domain variables use their wire representation, and responses do not recover the original record's protobuf kinds. Keep exact financial calculations in CEL or ledger operations and check conversions at that boundary.

## Call the action as a tool

A `tool` block makes a Starlark action callable. The caller supplies input, validated against the tool's input schema, and receives the output synchronously. The call runs inside the caller's transaction and as the caller, so its writes commit or roll back with the caller's other operations. Give the tool a registry `name` so agents can reference it.

```graphql
mutation CreateProvisioningTool {
  actions {
    createAction(
      id: "8a51f0d3-2f63-4a7b-9a56-44f3f1a6d0c2"
      input: {
        name: "provision_journal"
        description: "Provision one journal for an account"
        kind: "tool"
        tool: {
          name: "provision_journal"
          description: "Create the dedicated journal for an account if it does not exist."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" }, code: { type: "string" } }
            required: ["account_id", "code"]
            additionalProperties: false
          }
          outputSchema: { type: "object" }
        }
        config: {
          runtime: "starlark"
          src: """
          def run(event):
              account = event["data"]
              found = graphql(
                  "query Find($id: UUID!) { journal(id: $id) { journalId code } }",
                  {"id": account["account_id"]},
              )
              if found["journal"] != None:
                  return {"outcome": "already-provisioned"}
              graphql(
                  "mutation Create($id: UUID!, $code: String!) { createJournal(input: {journalId: $id, code: $code, name: $code}) { journalId } }",
                  {"id": account["account_id"], "code": account["code"]},
              )
              return {"outcome": "provisioned"}
          """
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

The action has no triggers, so it never runs for events. Wait for `ACTIVE`, then call version 1 with a fresh `executionId`:

```graphql
mutation CallProvisioningTool {
  actions {
    invokeAction(
      id: "8a51f0d3-2f63-4a7b-9a56-44f3f1a6d0c2"
      input: {
        executionId: "4c0f1d5e-4b4b-4f53-9c1e-1f6f9d2b7a31"
        actionVersion: 1
        input: { account_id: "b8e1f7a2-6d3c-4b8e-9f1a-2c3d4e5f6a7b", code: "TOOL-JOURNAL" }
      }
    ) {
      executionId
      status
      event {
        eventType
        data
      }
      invocation {
        output
      }
    }
  }
}
```

The response is the tool's `SUCCEEDED` execution with `eventType: "action.invoked"` and the output. Repeating the same mutation returns that execution without running the tool again; changing the input under the same `executionId` is an error. Input that fails the schema fails before the program runs. A call that fails returns a `FAILED` execution whose `invocation.error` has a `code`, a `message`, and a `hint` saying what to change; none of the call's writes remain, and the request's other operations can still commit. A write conflict or an interrupted transaction instead fails the whole request with a retryable error.
