# Split, merge, supersession

> How Noryo corrects the identity of a series without ever erasing a `series_id`, and how to map your old identifiers to the new ones on read-back.

A series (`series`) groups occurrences that are economically linked over time, within your workspace. Its `series_id` is stable: it is derived from what does not change (workspace, family, counterparty, direction, currency), never from the latest occurrence or the current descriptor. One more occurrence does not create another series.

Even so, with more occurrences Noryo may come to understand that one name was covering two flows, or that two names were pointing at a single flow. This page describes what happens then, and what you need to read.

## A third axis, separate from the other two

A series carries three axes that the contract refuses to blend:

| Axis | Field | Question it answers |
| --- | --- | --- |
| Solidity | `status` (`emerging`, `established`, `uncertain`, `broken`) | How much of a series is this? |
| Lifecycle | `lifecycle.state` (`active`, `late`, `inactive`, `ended`, `unknown`) | What is happening to it now? |
| Identity | `identity.status` (`current`, `superseded`) | Is its name still the right one? |

A series can debit every month, be firmly established, and still have changed identity because Noryo worked out that it was in fact two flows, or two halves of one. The first two axes have their own page ([Recurring series](https://developer.noryo.app/en-GB/transaction-intelligence/recurring-series), [Series lifecycle](https://developer.noryo.app/en-GB/transaction-intelligence/series-lifecycle)); this one covers only the third.

## Split (`split`)

A counterparty is not a series: the same counterparty can carry several flows, for instance two subscriptions with the same service. Early on, those flows may sit under a single name. Once the occurrences show two flows that coexist in time, Noryo splits: the old identity is marked `superseded`, and each flow receives its own.

After a split there is no single redirect. Neither successor is "the continuation" of the old name: half of the old name's occurrences are not in either of them. That is why `superseded_by.successor_series_ids` is a list, and why electing one canonical successor would be wrong.

## Merge (`merge`)

The reverse case: two series that Noryo had kept apart were in fact a single flow. Both identities are then replaced by a new one: each of the two old identities is marked `superseded`, and its `successor_series_ids` list holds a single identifier, the new one.

## Supersession: what becomes of the old name

In both cases, a `series_id` is never rewritten or silently reused. The old name remains, marked `superseded`, and it carries its successors. It stays valid as a historical reference and will not disappear, but future occurrences will carry the name of its successors.

**Schema `SeriesIdentity`**

A third axis, and it mixes with neither `status` nor `lifecycle`. `status` says how strongly we believe this is a series; `lifecycle` says what is happening to it economically; `identity` says whether its name is still the right one. A series can debit every month and still have changed identity because we worked out that it was in fact two flows, or two halves of one. And a `series_id` is never rewritten or silently reused. When we correct, the old name remains, marked `superseded`, and it carries its successors.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | `current`: this name is the current name of this flow. `superseded`: this name has been replaced by an identity correction. It stays valid as a historical reference (it will not disappear), but future occurrences will carry the name of its successors. Values: `current`, `superseded`. |
| `superseded_by` | object \| null | — | After a split there is no single redirect, and that is why this field returns a list. If we discover that a series was mixing two flows, neither successor is "the continuation" of the old one: half of the old one's occurrences are missing from each of them. Electing a canonical one would be comfortable and wrong. |
| `superseded_by.reason` | string | — | `split`: this identity was covering several, and each now has its own. `merge`: this identity and another were the same flow; both are replaced by a new identity. Values: `split`, `merge`. |
| `superseded_by.at` | string · date-time | — | — |
| `superseded_by.evidence` | array<string> | — | The evidence codes that authorised the correction. Never an internal score, never a row identifier. |
| `superseded_by.successor_series_ids` | array<string> | — | One after a merge, several after a split. Never empty when `status` is `superseded`. |
| `supersedes` | array<string> | yes | The way back, and it matters as much as the other one. The identities this one replaces. Without it, the old `series_id` you had stored would no longer correspond to anything you receive, and you would have no way to make the link. Empty for a series that was not born of any correction. |

- `superseded_by` is `null` on a current identity. Otherwise it carries `reason` (`split` or `merge`), `at`, `evidence` (the evidence codes that authorised the correction, never an internal score) and `successor_series_ids`, which is never empty when `status` is `superseded`.
- `supersedes` is the way back: the identities this one replaces. Empty for a series that was not born of any correction.

On a superseded series, `lifecycle.state` is `unknown` and `lifecycle.identity_status` is `superseded`: this is not economic ignorance, the verdict now belongs to the successors. That field is only a pointer to `identity` and never takes the value `current`.

> **Note** The contract's `serie_scindee` example shows three evidence codes for a split: `two_supported_partitions`, `time_overlap`, `basis_amount`. No threshold accompanies them, and you do not need to reproduce them.

## Where the occurrence came from, and where it is now

When an identity is corrected, the occurrence changes series. Without the `membership` block you would have no way to relate the `series_id` you had stored to the one you receive. You never have to rebuild the graph yourself.

**Schema `SeriesMembership`**

Where this occurrence came from, and where it is now. When an identity is corrected, the occurrence changes series: without this block, you would have no way to relate the `series_id` you had stored to the one you receive. You never have to rebuild the graph yourself.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `original_series_id` | string \| null | — | The series this occurrence was attached to the first time. Equal to `current_series_id` for as long as no correction has taken place. Pattern: `^ser_[0-9a-f-]{36}$`. |
| `current_series_id` | string \| null | yes | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `corrections` | array<object> | yes | The successive moves, from oldest to most recent. Empty for an occurrence that has never moved. |
| `corrections[].reason` | string | — | Values: `split`, `merge`. |
| `corrections[].from_series_id` | string \| null | — | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `corrections[].to_series_id` | string \| null | — | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `corrections[].at` | string · date-time | — | — |

`original_series_id` is the series the occurrence was attached to the first time; it stays equal to `current_series_id` for as long as no correction has taken place. `corrections` lists the moves from oldest to most recent, each with `reason`, `from_series_id`, `to_series_id` and `at`.

Excerpt of `series[]`, occurrence whose series was split:

```json
{
  "series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
  "identity": {
    "status": "superseded",
    "superseded_by": {
      "reason": "split",
      "at": "2026-09-10T22:14:00Z",
      "evidence": ["two_supported_partitions", "time_overlap", "basis_amount"],
      "successor_series_ids": [
        "ser_6ba7b812-9dad-11d1-80b4-00c04fd430c8",
        "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8"
      ]
    },
    "supersedes": []
  },
  "membership": {
    "original_series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
    "current_series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "corrections": [
      {
        "reason": "split",
        "from_series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
        "to_series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
        "at": "2026-09-10T22:14:00Z"
      }
    ]
  }
}
```

The old name announces two successors; the `membership` block says which of the two this particular occurrence moved to.

## Mapping your old `series_id` values to the new ones

The series returned by `GET /transactions/v1/enrichments/{enrichment_id}` reflect what Noryo knows at the time of reading, not at the time of writing: this is already how `status` can turn to `broken` through the passage of time alone. A correction shows up on read-back, as well as in the response to a new submission: the contract's `serie_scindee` example is a `201` response. The read-back window is described in [Read an enrichment](https://developer.noryo.app/en-GB/transaction-intelligence/read-an-enrichment).

| You read | You conclude |
| --- | --- |
| `identity.status = current`, `supersedes` empty | Nothing has changed. |
| `identity.status = superseded` | The name you stored is historical. Keep it as an alias and record `successor_series_ids` together with the `reason`. |
| `membership.current_series_id` differs from `original_series_id` | This occurrence now belongs to `current_series_id`. After a split, this field alone says which successor it went to. |
| A `series_id` you have never seen, with a non-empty `supersedes` | A new identity replacing identifiers you may already know. Link them. |

```ts
const enrichmentId = process.argv[2]; // enr_…
const res = await fetch(
  `https://api.noryo.app/transactions/v1/enrichments/${enrichmentId}`,
  { headers: { Authorization: `Bearer ${process.env.NORYO_API_KEY}` } },
);
if (!res.ok) throw new Error(`read-back refused: ${res.status}`);
const stored = await res.json();

const lineage = new Map<string, string[]>(); // old series_id -> successors
const moved: Array<{ from: string; to: string }> = [];

for (const s of stored.series ?? []) {
  // 1. Is the stored name superseded? (one old -> one or several new)
  if (s.identity?.status === "superseded") {
    lineage.set(s.series_id, s.identity.superseded_by.successor_series_ids);
  }
  // 2. The way back: does a new name replace old ones?
  for (const replaced of s.identity?.supersedes ?? []) {
    lineage.set(replaced, [...(lineage.get(replaced) ?? []), s.series_id]);
  }
  // 3. Where is this occurrence now? (per occurrence, never wholesale)
  const m = s.membership;
  if (m?.original_series_id && m.current_series_id !== m.original_series_id) {
    moved.push({ from: m.original_series_id, to: m.current_series_id });
  }
}
console.log({ lineage: Object.fromEntries(lineage), moved });
```

Two mistakes to avoid:

- Replacing the old identifier wholesale with the first successor in the list. After a split, every occurrence has its own `current_series_id`.
- Reading `lifecycle.state = unknown` on a superseded series as a flow whose fate is unknown. The lifecycle is read on the successors.

In every case, `original_series_id` does not move: only `current_series_id` advances, and `corrections` keeps the path.

## See also

- [Recurring series](https://developer.noryo.app/en-GB/transaction-intelligence/recurring-series): `series_id`, `type`, `status`, cadence.
- [Series lifecycle](https://developer.noryo.app/en-GB/transaction-intelligence/series-lifecycle): `lifecycle.state` and observation coverage.
- [Reference](https://developer.noryo.app/en-GB/transaction-intelligence/reference): the `Series`, `SeriesIdentity` and `SeriesMembership` schemas generated from the contract.

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/split-merge-supersession
