Skip to main content
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 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.

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 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.
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 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.
  • 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.

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:
Other targets and event types produce different keys, one per table the event touched. A few examples:
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:
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 of the target itself, the target’s own entry is an empty {}. Custom attributes saved during that same operation still carry their new values.
  • When one operation changes several records of the same type, changes reports one set of values for that type.
  • A custom attribute saved as blank, zero, or false is left out of changes entirely, so an empty value and an untouched one look the same.
  • step_id, run_step_id, and a redline’s initial_state and final_state never appear in it.
changes reports only what the current operation modified. A missing or empty key means that operation didn’t touch the field, not that the entity has no value for it. To confirm a record holds a value, request the field in your context query and read it off the entity instead.
To require that a run carries a Department custom attribute, request the attributes in your context query and check the entity:
Reading the same check from changes would let a run through whenever the operation left the attribute untouched, such as an edit that only moves the due date.

Current user

currentUser is always present and describes who performed the change:
Branch on roles to gate a change by permission:

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.
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.

How multiple actions combine

When an event and target combination matches several actions, those actions are squashed together at run time. 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.
For example, two actions are both configured with event CREATE and target RUNS:
ION Action 535 configuration: on create runs, selects dueDate in context and ensures it is filled out.

Action 535: on create of a run, checks that dueDate is filled in.

ION Action 240 configuration: on create runs, selects procedureId in context and ensures it is filled out.

Action 240: on create of a run, checks that procedureId is filled in.

When a run is created, both actions trigger and are squashed into a single execution flow, shown in the execution log:
Execution log code for creating a run with actions 535 and 240 squashed together.

Execution log code for creating a run with both actions enabled.

The squashed code includes a few standard pieces:
  • ValidationError: a class defined in every execution log. If an action raises it, the action’s ID and name are injected into the code so it’s clear which action caused the failure.
  • run_rule: wraps each triggered action into one executable unit and runs them in sequence, with validation actions before warning actions so a block takes precedence.
  • ctx: the combined context for all squashed actions. Each action declares the fields it needs, and squashing merges them into one context object.
In the example, action 535 requests dueDate and action 240 requests procedureId, so the resulting context contains both:
Execution context combining dueDate and procedureId requested by both actions.

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.
  • Detect a status change. Compare the new and old values in changes rather than the final state.
  • Find an attribute in a list. Attributes come back as a list of {key, value}. Pull one with next and a default.

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.

Troubleshooting

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; see Manage actions. A custom-attribute change fires an update on the parent entity, not on a separate attribute target.
Check the action’s 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. An action with either problem is saved but sits in an error status and doesn’t run until you fix it.
ValidationError keeps only a plain string-literal message. A message built from an f-string, a variable, or any other expression is dropped, and the user sees just [Rule {id}] {title}. Pass a literal string.
The context query must request every field your code reads. Field names are case-sensitive, so partNumber works and partnumber doesn’t. Custom attributes use a capital Attributes. On a delete event, the query runs against the pre-deletion state.
The action exceeded its computation budget. Simplify the logic, filter attributes in the query with Attributes(filters: {key: {eq: "..."}}) instead of fetching all of them and filtering in code, or split the work into several smaller actions.
Attribute changes appear under the entity’s *Attributes key in changes, such as partsInventoriesAttributes or runsAttributes. They fire an update on the parent entity and are keyed by the attribute’s name, not a column name.

Starting points

Most orgs start with a small set of high-value rules, such as requiring a defect type before an issue can save or blocking run closure while the aBOM is incomplete. The example actions collect working configurations you can copy and adapt.