Skip to content
Sandbox

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.

View as Markdown

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:

AxisFieldQuestion it answers
Soliditystatus (emerging, established, uncertain, broken)How much of a series is this?
Lifecyclelifecycle.state (active, late, inactive, ended, unknown)What is happening to it now?
Identityidentity.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, 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.

  • statusstringrequired

    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.

    currentsuperseded
  • superseded_byobject· nullable

    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.

  • reasonstring

    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.

    splitmerge
  • atstring · date-time
  • evidencearray<string>

    The evidence codes that authorised the correction. Never an internal score, never a row identifier.

  • successor_series_idsarray<string>

    One after a merge, several after a split. Never empty when status is superseded.

  • supersedesarray<string>required

    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.

  • original_series_idstring· nullable

    The series this occurrence was attached to the first time. Equal to current_series_id for as long as no correction has taken place.

    ^ser_[0-9a-f-]{36}$
  • current_series_idstring· nullablerequired
    ^ser_[0-9a-f-]{36}$
  • correctionsarray<object>required

    The successive moves, from oldest to most recent. Empty for an occurrence that has never moved.

  • reasonstring
    splitmerge
  • from_series_idstring· nullable
    ^ser_[0-9a-f-]{36}$
  • to_series_idstring· nullable
    ^ser_[0-9a-f-]{36}$
  • atstring · 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:

{
  "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"
      }
    ]
  }
}
JSON

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.

You readYou conclude
identity.status = current, supersedes emptyNothing has changed.
identity.status = supersededThe 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_idThis 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 supersedesA new identity replacing identifiers you may already know. Link them.
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 });
TypeScript

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: series_id, type, status, cadence.
  • Series lifecycle: lifecycle.state and observation coverage.
  • Reference: the Series, SeriesIdentity and SeriesMembership schemas generated from the contract.

Published on 2026-09-17 11:19 UTC (version 1).

Was this page helpful?