> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stratumn.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Trace migrations

> Change the data of existing traces declaratively, auditably and repeatably

A **migration** changes the state data of traces that already exist.

You declare it once in the configuration project. An administrator then runs it, and
the platform writes **one signed link per trace** — so every change is attributable,
visible in the trace history and impossible to confuse with a manual database edit.

<Note>
  A migration reuses everything you already know about
  [effects](/configuration/action/effects). What differs is where you declare it: its
  own `migrations` map, not `actions`.
</Note>

## What you write, and what you don't

This is the whole contract on the configuration side:

| You write                                      | The platform does                                                            |
| ---------------------------------------------- | ---------------------------------------------------------------------------- |
| the migration object — `filters` and `effects` | turns it into an action the engine can run on every trace                    |
| one line in `migrations`                       | keeps the migration out of `initActions`                                     |
| a `traceBot` team                              | marks the migration link as *transitive*, so the state machine does not move |
|                                                | stamps each trace so a re-run skips it                                       |

You never write an `initActions` entry, a group grant, a `transitions` entry,
`hidden: true`, or a "mark as applied" call. If you find yourself adding any of
those, something is wrong.

## 1. Declare the migration

<CodeGroup>
  ```ts migrateDateToIso.ts theme={null}
  import { execJs } from '@stratumn/dsl';
  import type { IMigrationDef } from '@stratumn/trace-generator';
  import type { DslContext } from 'workflows/pastaLaVista/types/dsl.types';
  import type { PastaLaVistaStateData } from '../types/state.types';

  function migrateDateToIsoEffect(dsl: DslContext<Record<string, never>>) {
    const { state } = dsl.$variables;

    // biome-ignore lint/performance/useTopLevelRegex: the effect runs in a sandbox, so
    // hoisting this out of the function would make it undefined at runtime.
    const legacyDate = /^(\d{2})\/(\d{2})\/(\d{4})$/;

    const match = legacyDate.exec(state.data.creationDate ?? '');
    if (!match) {
      return;
    }

    const [, day, month, year] = match;
    state.data.creationDate = `${year}-${month}-${day}`;
  }

  export const migrateDateToIso: IMigrationDef<PastaLaVistaStateData> = {
    title: 'Normalize creationDate to ISO 8601',
    icon: 'lucide_CalendarSync',
    filters: [
      { type: 'string', field: 'creationDate', operator: 'contains', value: '/' }
    ],
    effects: [execJs(migrateDateToIsoEffect)]
  };
  ```
</CodeGroup>

<Tabs>
  <Tab title="API">
    <ParamField path="title" type="string" required>
      Human-readable label, shown in the run history and in the trace timeline.
    </ParamField>

    <ParamField path="filters" type="ArchiveCondition<TState>" required>
      Which traces the migration applies to. Non-empty by type: an empty filter would
      target every trace in the workflow, so it does not compile.
    </ParamField>

    <ParamField path="effects" type="IEffectDef[]" required>
      The data transformation, exactly as in a normal action.
    </ParamField>

    <ParamField path="icon" type="string">
      Icon shown alongside the migration.
    </ParamField>

    <ParamField path="description" type="string">
      Longer explanation of what the migration does.
    </ParamField>

    <ParamField path="stageName" type="string">
      Defaults to the key you register the migration under.
    </ParamField>
  </Tab>

  <Tab title="Type">
    ```ts Typescript theme={null}
    type IMigrationDef<TState> = {
      title: string;
      filters: NonEmptyArchiveCondition<TState>;
      effects: IEffectDef[];
      icon?: string;
      description?: string;
      stageName?: string;
    };
    ```

    There is no `key`, no `form`, no `hidden` and no `isMigration`: the key is the one
    you register it under, and the rest is filled in for you.
  </Tab>
</Tabs>

### Writing the filter

`filters` is the same shape as a workflow's
[`archiveCondition`](/configuration/workflow-definition), so `field` is typed
against your state: **a misspelled field name is a compile error.**

```ts theme={null}
filters: [
  { type: 'string',  field: 'creationDate',  operator: 'contains', value: '/' },
  { type: 'number',  field: 'amount',        operator: '<',        value: 10 },
  { type: 'boolean', field: 'closed',        operator: '=',        value: false }
]
```

Rules are combined with **AND**. Available operators by `type`:

| `type`         | operators                                                                                       |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `string`       | `=` `!=` `contains` `startsWith` `endsWith`                                                     |
| `number`       | `=` `!=` `>` `>=` `<` `<=`                                                                      |
| `boolean`      | `=` `!=`                                                                                        |
| `relativeDate` | `>` `<` — `value` is a whole number of days from now: negative is the past, positive the future |

<Warning>
  **The filter is what bounds the blast radius.** Do not widen it and sort things out
  inside the effect — write the narrowest filter that selects the rows you mean.

  There is no regex operator. If the shape you need cannot be expressed, prefer
  `contains` on a distinguishing character (ISO dates never contain `/`, legacy
  `DD/MM/YYYY` always does) over selecting everything.
</Warning>

Traces where the field is **absent** are never selected, so you do not have to guard
against missing data in the filter.

### Writing the effect

The effect body contains **only** the transformation. No bookkeeping.

<Warning>
  **Always return early when there is nothing to change.** The filter and the effect
  have to agree on what "already correct" means, and only the effect can be sure.

  ```ts theme={null}
  const match = legacyDate.exec(state.data.creationDate ?? '');
  if (!match) {
    return; // already ISO, or no date at all — leave it alone
  }
  ```

  This is what keeps a migration safe when the filter selects more than you expected —
  and a filter eventually will, because it cannot express everything the effect can
  check. Without the guard, a trace the filter caught by accident is silently rewritten
  and stamped as migrated, and there is nothing to tell you it happened.

  A no-op effect still writes a link and still stamps the trace. That is intended: it
  records that the migration considered this trace and found nothing to do.
</Warning>

<Note>
  **If assigning to the field is a compile error**, the field is read-only because it
  reaches your state type only through `initialStateData`, which is declared
  `as const`. `typeof initialStateData` then types it as a readonly tuple of literals,
  which no effect can assign to:

  ```
  Cannot assign to 'labels' because it is a read-only property.
  ```

  Migrate a field your state type declares explicitly instead, or widen the field in
  the state type first. Do not reach for a cast — the error is telling you the type
  says this data never changes, which is worth fixing rather than silencing.
</Note>

## 2. Register it in `migrations`

In the workflow's `config`, beside `actions` — **not inside it**:

```ts pastaLaVista/index.ts theme={null}
import { migrateDateToIso } from './migrations/migrateDateToIso';

export const pastaLaVista: BetterWorkflowDef<...> = {
  config: {
    actions: { ...pastaLaVistaActions },
    migrations: { migrateDateToIso },
    // ...
  }
};
```

**The key you use here is the migration's identity.** It is what an administrator
types to run it, and the key under which each migrated trace is stamped — so treat it
as permanent.

<Note>
  Keeping migrations out of `actions` is deliberate. It means a grant built by mapping
  over every action (`initActions: { kitchenTeam: allActions }`) cannot pick a
  migration up. Config assembly strips them from `initActions` as well, so you have
  nothing to remember either way: a migration is never offered to a user because it is
  never in anyone's next actions.
</Note>

## 3. Declare the `traceBot` team

Every migration link is authored by the group labelled **`traceBot`**. Declare it like
any other team, in the same organization as your workflow:

```ts account/teams.ts theme={null}
export const traceBot = {
  name: 'Trace Bot',
  description: 'Acting group for engine-driven actions such as migrations.',
  organizationName: pastaLaVistaOrganization.name,
  users: {},
  includeSuperUser: true,
  avatar: `${__dirname}/files/trace-bot.png`
} as const satisfies ITeamDef;

export const teams = { kitchenTeam, serviceTeam, traceBot } as const;
```

<Warning>
  Two things here are not optional.

  **The map key must be exactly `traceBot`.** Group labels are generated from the keys
  of this map, and the platform acts as that exact label.

  **`includeSuperUser: true` is required.** It is what puts the administrator running
  the migration into the group. Without it, every link is rejected with
  *"the authenticated account is not part of the link group"*.
</Warning>

The group is only created when the workflow's configuration is deployed, so **deploy
the config before running a migration**. Re-deploying is safe: an existing group is
reused, never duplicated.

<Tip>
  Get either of these wrong and you will not get a broken job — the platform refuses
  the migration up front with a message naming the fix.
</Tip>

## 4. Deploy, then hand over

Deploy the configuration as usual. From then on it is an administrator's job to run
the migration; nothing further is needed from the configuration project.

They will always **dry-run first**, which reports how many traces match and changes
nothing.

## Re-running a migration

When a migration is applied to a trace, the platform stamps it — in its own field on
the trace's state, beside `data`, never inside it:

```json theme={null}
{
  "data": { "creationDate": "2026-04-07" },
  "migrationsApplied": {
    "migrateDateToIso": "2026-09-04T10:15:16.073Z"
  }
}
```

Selection skips any trace already carrying the key, which gives you three things for
free:

* running the same migration twice is a **no-op**
* if some traces fail, **re-running retries only those**
* each migration is stamped under its own key, so migrations never hide each other

You do not write this stamp, and you cannot forget it. It is **read-only**: the
platform recomputes it on every link, so assigning to it from an effect does nothing.
Keeping it out of `data` is what makes it survive an action that replaces `data`
wholesale.

Should you ever need to branch on it, it is typed on the state:

```ts theme={null}
if (state.migrationsApplied?.someOtherMigration) {
  return;
}
```

## One migration, one file, one key

**A migration that has run is finished. Never edit it.**

Once a migration has run anywhere, treat its file as closed. Need a different
transformation, a corrected filter, or a second pass over the same field? **Add a new
migration**: a new file, a new key, a new entry in `migrations`.

```
migrations/
├── migrateDateToIso.ts          ← ran in March. Never touched again.
├── migrateStatusEnum.ts         ← ran in June. Never touched again.
└── migrateDateToIsoTimezone.ts  ← the follow-up fix, as its own migration
```

This is a rule, not a preference, and there are two reasons for it.

**The key is the stamp.** Traces already migrated carry that exact key, and that is the
only thing stopping them being processed again. Rename the key and every one of those
traces is selected on the next run. Keep the key but change the effect and the traces
that already ran will *never* get the new behaviour — the two failure modes are
opposites, and neither announces itself.

**The links are the audit trail.** Each run leaves one signed link per trace saying
what was applied. Editing a migration in place makes that record a lie: two traces show
the same action key having done two different things, and nothing distinguishes them.

<Note>
  Keeping old migration files costs nothing. A migration that has already run selects
  no traces, so leaving it in `migrations` is free — and it is the record of what was
  done to the data.
</Note>

## How a migration appears to users

* **Business users never see it.** It is not in anyone's next actions, so it is never
  offered, and its links are hidden from the trace history.
* **Administrators** can reveal the links with a toggle in the trace header, where they
  are marked distinctly so they cannot be mistaken for business events.
* The run itself is visible in the workflow's **batch action history**, to
  administrators only, with progress and any failures.

## Checklist

Before asking for a migration to be run:

* [ ] it is a **new file under a new key** — no migration that has already run was edited
* [ ] `filters` selects only the traces you mean — check the count with a dry run
* [ ] the effect **returns early** when there is nothing to change
* [ ] the migration is registered in `config.migrations`
* [ ] a `traceBot` team exists, keyed `traceBot`, with `includeSuperUser: true`
* [ ] the configuration has been deployed

<CardGroup cols={2}>
  <Card title="Effects" icon="code" href="/configuration/action/effects">
    The business logic a migration reuses
  </Card>

  <Card title="Action definition" icon="book-open" href="/configuration/action/definition">
    Everything else an action can declare
  </Card>

  <Card title="State" icon="database" href="/configuration/state">
    The data a migration changes
  </Card>

  <Card title="Accounts" icon="user-group" href="/configuration/accounts">
    Teams, users and organizations
  </Card>
</CardGroup>
