## ION Actions

**ION Actions** are validation rules you write to enforce your own business logic in ION. An action is triggered by an event, such as creating an issue, completing a run step, or changing a procedure’s status. When triggered, it reads the data involved and can allow the change, block it with a message, or surface a non-blocking warning. Actions run inside the change itself: a blocked change is rejected before it saves, so the rule is enforced whether the change comes from the ION UI or the API. That makes actions the right tool for hard requirements, such as required fields on creation, approval gates, and status-transition guards. For alerting people after something happens, use [notifications](https://docs.firstresonance.io/automate-with-ion/notifications/subscribe-to-notifications) instead. You create and edit actions on the **Actions** page in ION, which has a built-in editor, or through the GraphQL API for programmatic deployment. For the create, edit, enable, and delete flows, and for enabling actions for your organization, see [Manage actions](https://docs.firstresonance.io/automate-with-ion/ion-actions/manage-actions).

## Action anatomy

An action is defined by these parts. Title, target, and event are form fields; context and code are tabs in the built-in editor.

- **Title**: identifies the action and appears in its toast notification and the audit log.
- **Target**: the entity to watch, such as **Run**, **Issue**, or **Procedure**, chosen from a dropdown. See [Action targets](https://docs.firstresonance.io/automate-with-ion/ion-actions/action-targets) for the full list.
- **Event** (labeled **Event Type** in the editor): when the action runs against the target, one of **Create**, **Update**, or **Delete**.
- **Context**: a GraphQL query for the data your code needs, such as the target’s fields, related records, and custom attributes. Your code reads from the result.
- **Code**: a Python script that runs when the event fires. It reads the context and decides the outcome: raise `ValidationError` to block the change with a message, raise `ValidationWarning` to warn without blocking, or do neither to allow it. The code is read-only. It can’t write data or call external systems.

## Context query

The context is a GraphQL query that fetches the data your code evaluates. ION runs it against the entity that triggered the action and passes the result to your code.

```graphql
{
  run(id: $id) {
    id
    status
    Attributes { key value }
  }
}
```

Rules for the query:

- **One entity, plus an optional `me`.** Query exactly one top-level entity, matching your target. You can add `me { ... }` as a second top-level selection for data about the acting user.
- **Use the singular camelCase name** of the target as the query name, such as `run` for the `runs` target or `runStep` for `run_steps`. See [Action targets](https://docs.firstresonance.io/automate-with-ion/ion-actions/action-targets) for the name of every target. The fields each entity exposes are what GraphiQL introspection returns for it in your environment.
- **`$id` is resolved for you.** ION substitutes the triggering entity’s ID, so `$id` is a convention you don’t fill in. Compound-key entities take their composite ID parameters instead; see [Action targets](https://docs.firstresonance.io/automate-with-ion/ion-actions/action-targets).
- **No `edges` and one operation.** Don’t use the connection or `edges` pattern, and define only one query.

### Custom attributes

Custom attributes come back as a list under `Attributes`. Request all of them with `Attributes { key value }`, or filter to one with `Attributes(filters: {key: {eq: "Pedigree"}})`. Each attribute object exposes these fields:

- `key`: the attribute name, such as “Pedigree” or “Release Date”.
- `value`: the value, which can be a string, number, date, boolean, or select value.
- `id`: the attribute record ID.
- `type`: the attribute type.
- `allowedIonType`: the linked entity type, when the attribute links to one.
- `options`: the allowed values for a select attribute.

## Data your code receives

Your code gets one `context` dictionary. It always holds the entity you queried (under the target’s singular camelCase key), a `changes` object, and a `currentUser` object. It also holds `me` when your query requested it.

```python
context = {
    "run": { ... },          # the entity you queried; shape matches your query
    "changes": { ... },      # what this event modified
    "currentUser": { ... },  # who performed the change
}
```

### Changes object

`changes` describes what the event modified. It’s keyed by the camel-cased table name, then by field, and each entry holds `{ "new": ..., "old": ... }`. The old value is `null` on a create and the new value is `null` on a delete. Field names are snake-cased, and custom-attribute tables are keyed by the attribute’s name. A `changes` object for a procedure update can look like this:

```json
{
  "procedures": {
    "status": { "new": "released", "old": "in_review" }
  }
}
```

Other targets produce different keys, one per table the event touched. A few examples:

#### Issue update

```json
{
  "issues": {
    "status": { "new": "in_progress", "old": "pending" },
    "must_close_by_run_step_id": { "new": 2116, "old": null }
  },
  "issuesAttributes": {
    "Issue Origin": { "new": "Engineering", "old": null }
  },
  "issueApprovalRequests": {
    "status": { "new": "approved", "old": "pending" }
  }
}
```

#### Inventory update

```json
{
  "partsInventory": {
    "status": { "new": "unavailable", "old": "available" },
    "lot_number": { "new": "Lot Test 123", "old": null },
    "location_id": { "new": 19, "old": 67 }
  }
}
```

#### Part kit update

```json
{
  "partsKits": {
    "assigned_team_id": { "new": 2, "old": null },
    "delivery_location_id": { "new": 19, "old": null }
  },
  "partKitAttributes": {
    "Date Kitted": { "new": "2025-09-08T07:00:00", "old": null }
  }
}
```

Use `changes` to branch on what actually changed rather than the final state. For example, to act only when a procedure’s status changes to released:

```python
procedure_changes = context.get('changes', {}).get('procedures', {})
if procedure_changes.get('status', {}).get('new') == 'released':
    # the procedure was just released; run your logic here
```

Custom attributes appear under the entity’s `*Attributes` key, such as `partsInventoriesAttributes` or `runsAttributes`, keyed by the attribute’s name. A few things to know about `changes`:

- It’s operation-wide. It holds changes for every entity the operation touched, not only your action’s target.
- On a create or delete, the entity’s entry is an empty `{}`, since there are no field-level old and new values.
- `step_id`, `run_step_id`, and a redline’s `initial_state` and `final_state` never appear in it.

### Current user

`currentUser` is always present and describes who performed the change:

```json
{
    "email": "jane.doe@company.com",
    "roles": ["admin", "engineer"],
    "teams": ["Assembly", "QA"]
}
```

Branch on `roles` to gate a change by permission:

```python
if "admin" not in context.get("currentUser", {}).get("roles", []):
    raise ValidationError()
```

## Writing action code

Action code is the body of a Python function that receives `context`. You don’t write the `def` line: ION wraps your code and passes in the context. Raise `ValidationError` to block the change, raise `ValidationWarning` to warn, or return without raising to allow it.

```python
if context["run"]["status"] == "COMPLETE":
    raise ValidationError("Cannot modify a completed run")
```

The code runs in a sandboxed Python 3.6 environment:

- **Python 3.6 syntax only.** No walrus operator (`:=`), no f-string debug (`f"{x=}"`), no `match` statements, and no `X | Y` type unions.
- **No imports, no I/O.** You can’t import modules or reach the file system, network, or database. `print()` output is discarded.
- **You can** use the built-ins (`len`, `any`, `all`, `sum`, `next`, `set`, and so on), comprehensions, `try`/`except`, and local helper functions.
- **A computation budget applies.** A very long computation is stopped with a user-visible error, so avoid deep loops over large collections.

The built-in editor checks these constraints before an action goes live. An action that fails validation is saved but set to an `error` status and doesn’t run until you fix it.

### Blocking and warning

Raising `ValidationError` blocks the change; raising `ValidationWarning` lets it save and shows a non-blocking message. Use a validation action for hard requirements, such as a missing required field or an approval gate, and a warning action for soft ones, such as a skipped recommended step. An action raises one or the other, never both. You can pass a message to either. ION shows it to the user as `[Rule {id}] {title}` followed by your message; with no message, the user sees just `[Rule {id}] {title}`.

The message must be a plain string literal. ION injects the action’s ID and title automatically, and it silently drops a message built from an f-string, a variable, or any other expression, so the user sees only the title. This prevents dynamic strings from leaking internal data through a user-visible message.

```python
# Preserved: a string literal
raise ValidationError("Run must have a QMS code before creation")

# Dropped: the custom message never reaches the user
msg = "Run must have a QMS code"
raise ValidationError(msg)
raise ValidationError(f"Run {run_id} is missing a QMS code")
```

## How multiple actions combine

When an event and target combination matches several actions, those actions are squashed together at runtime. Squashing merges both the code and the requested context so every triggered action runs with the data it needs. They run in sequence in an order you don’t control, with validation actions before warning actions so a block takes precedence, and they share one merged context. Keep each action self-contained.

When two actions on the same target and event filter the same field differently in their context (for example, both query `Attributes(filters: {...})` with different keys), the merged query fails. Give each filtered field an alias, such as `deptAttr: Attributes(filters: {key: {eq: "Department"}})`, and reference the alias in your code.

## Common patterns

- **Read defensively.** A chained lookup like `context["run"]["procedure"]["type"]` throws if any level is `None`. Read one level at a time with a fallback: `context.get("run", {}).get("procedure", {}).get("type")`.
- **Skip early.** Return when the action doesn’t apply, so the rest of the code only handles the relevant case.

```python
if context.get("run", {}).get("procedure", {}).get("type") != "BUILD":
      return
```
- **Detect a status change.** Compare the new and old values in `changes` rather than the final state.

```python
changes = context.get("changes", {}).get("runSteps", {}).get("status", {})
if changes.get("new") == "canceled" and changes.get("old") != "canceled":
      raise ValidationError()
```
- **Find an attribute in a list.** Attributes come back as a list of `{key, value}`. Pull one with `next` and a default.

```python
pedigree = next(
      (attr.get("value") for attr in context.get("run", {}).get("Attributes", [])
       if attr.get("key") == "Pedigree"),
      None,
)
```

## Seeing what an action did

Every time an action fires, ION records the run in its execution logs: what triggered it, the data it read, and the outcome. Use the logs to debug an action that blocks unexpectedly or never fires. See [View action execution logs](https://docs.firstresonance.io/automate-with-ion/ion-actions/view-action-execution-logs).

## Troubleshooting

**An action never fires**: Confirm the action is enabled and its status is active, that the target matches the entity being changed, and that the event matches the operation. Actions must also be enabled for the organization.

**An action errors instead of running**: Check the action’s [execution logs](https://docs.firstresonance.io/automate-with-ion/ion-actions/view-action-execution-logs) for the error. Common causes are a context query that requests a field the entity doesn’t have, or a Python syntax error in the code.

**A custom message doesn't appear**: `ValidationError` keeps only a plain string-literal message. A message built from an f-string, a variable, or any other expression is dropped.

**Context data is missing**: The context query must request every field your code reads. Field names are case-sensitive.

**An action stops with a fuel-limit error**: The action exceeded its computation budget. Simplify the logic or filter attributes in the query.

**A custom attribute change isn't detected**: Attribute changes appear under the entity’s `*Attributes` key in `changes`, and trigger an `update` on the parent entity.
