# Series lifecycle

> What a series is going through right now, with the five lifecycle.state values, the observation coverage that lets an absence mean something, and the identity_status pointer after an identity correction.

## Three axes, never mixed

A `series` returned by the API carries three blocks that answer three different questions, and they must not be confused.

| Axis | Field | Question | Values |
|---|---|---|---|
| Establishment | `status` | How strongly does Noryo believe this is a series? | `emerging`, `established`, `uncertain`, `broken` |
| Lifecycle | `lifecycle.state` | What is happening to it right now? | `active`, `late`, `inactive`, `ended`, `unknown` |
| Identity | `identity.status` | Is its name still the right one? | `current`, `superseded` |

A very well-established series may have stopped collecting; a series that is still `emerging` may be perfectly alive. The first axis is covered in [Recurring series](https://developer.noryo.app/en-GB/transaction-intelligence/recurring-series), the third in [Split, merge, supersession](https://developer.noryo.app/en-GB/transaction-intelligence/split-merge-supersession). This page is about the second.

## The five states

Within the `lifecycle` block, only `state`, `policy_version` and `calibration` are required. `state` takes one of the following values, and each has a precise meaning.

| `state` | Meaning |
|---|---|
| `active` | The expected occurrence has arrived, or is not yet due. |
| `late` | One occurrence is missing, and the account was under observation. |
| `inactive` | Several consecutive occurrences are missing, under complete observation. The series is no longer collecting, and Noryo does not know why. |
| `ended` | Reserved for explicit terminal evidence. No accumulation of absences ever produces this state. |
| `unknown` | Noryo cannot conclude, and `reason` says why. |

`late` and `inactive` both require that the account was observed: they are never inferred from silence alone. `ended` is not inferred from silence either, however long: a payment that stops may be a cancellation, a suspension, an expired card or a failed collection, and the API does not guess which. When `ended` is returned, `evidence` carries the terminal proof that produced it.

> **Note** `unknown` is a result, not a failure: `reason` gives the cause in one sentence.

## An absence proves nothing without coverage

The lifecycle rests on a simple distinction: "I saw nothing" is not "nothing happened". If your integration stops sending the operations of an account, every subscription on that account will appear to stop. For a missing occurrence to mean anything, Noryo must know that the account was observed, and observed completely.

That is what [observation coverage](https://developer.noryo.app/en-GB/transaction-intelligence/observation-coverage) is for. Through `POST /transactions/v1/coverage-windows`, you declare that every operation on an account over an interval has been supplied (`completeness = complete`). `partial` and `unknown` do not qualify: ten operations in October may be ten out of a hundred. The declaration is recorded as stated, with `provenance = caller_asserted`: Noryo does not verify a bank's completeness.

The `lifecycle` block reports what it could verify in `observation_coverage`:

| `observation_coverage` | Meaning |
|---|---|
| `not_required` | No occurrence has fallen due: nothing needed observing. |
| `complete` | Every source of the series was covered over the expected window. A gap is a gap. |
| `incomplete` | At least one source was not. The absence proves nothing, and `state` is `unknown`. |
| `unknown` | The sources are unknown, or too numerous to check. |

Until coverage is `complete`, no missing-occurrence state (`late`, `inactive`) is concluded. The `consecutive_misses` counter only advances under complete coverage; it is absent when nothing is expected, which is not the same ignorance as "we were not looking".

> **Warning** Coverage is tied to the series through the account: `account_hint` (`iban` or `external_account_id`) must be the same on the enriched transaction and on the declared window. A transaction sent without `account_hint` has no source to cover, and its `lifecycle.state` stays `unknown`.

## A window, not a date

When an occurrence is expected, `expected_next_window` gives the interval: `from`, `to`, and `tolerance_days`, the half-width in days. A salary lands around the last working days, a SEPA direct debit around the 5th: an exact instant would be a precision Noryo does not have. The width comes from the anchor type, returned in `anchor`; `widened_to_family_floor` indicates whether the tolerance was raised to the floor of the series family.

Two fields are duplicated here so the lifecycle can be read on its own: `cadence_strength` is the same number as `cadence_detail.strength` (class `calendar_fill`), and `calendar_anchor` the same object as `calendar_pattern` (class `anchor_stability`). They are not additional scores, and neither is a probability: see [Score semantics](https://developer.noryo.app/en-GB/transaction-intelligence/score-semantics). `calendar_anchor` is `{ "type": "unknown" }` when no anchor was inferred, and it is absent when no occurrence is expected. `last_observed_on` gives the last day an occurrence was seen.

## When the identity has been corrected

After an identity correction (`split` or `merge` in `superseded_by.reason`), the old `series_id` remains readable, but its lifecycle no longer belongs to it: the verdict now belongs to its successors. The block then returns `state = unknown` with `identity_status = superseded`. This field is present only in that case and never takes the value `current`. It is not economic ignorance, it is a pointer: the full identity state, with `superseded_by` and `supersedes`, is read in `identity`, and the occurrence's move in `membership`. The [Split, merge, supersession](https://developer.noryo.app/en-GB/transaction-intelligence/split-merge-supersession) page covers both blocks in detail.

## Reading the block

A live monthly series, taken from the contract:

```json
{
  "state": "active",
  "reason": "la prochaine echeance n'est pas encore echue",
  "last_observed_on": "2026-09-05",
  "expected_next_window": {
    "from": "2026-10-03",
    "to": "2026-10-07",
    "tolerance_days": 5,
    "anchor": "fixed_day_of_month",
    "widened_to_family_floor": true
  },
  "consecutive_misses": 0,
  "observation_coverage": "complete",
  "cadence_strength": 1,
  "calendar_anchor": {
    "type": "fixed_day_of_month",
    "nominal_day": 5,
    "weekday": null,
    "day_range": [4, 6],
    "weekend_shifts": 0,
    "strength": 0.67,
    "calibration": "non_calibree"
  },
  "policy_version": "cyc_v2",
  "calibration": "non_calibree"
}
```

The same series after a split, read through its former `series_id`:

```json
{
  "state": "unknown",
  "reason": "identite corrigee : le cycle de vie appartient desormais aux successeurs",
  "identity_status": "superseded",
  "last_observed_on": "2026-09-05",
  "policy_version": "cyc_v2",
  "calibration": "non_calibree"
}
```

The full schema:

**Schema `SeriesLifecycle`**

An axis different from `status`. `status` says how strongly we believe this is a series; `lifecycle` says what is happening to it now. A very well-established series may have stopped collecting, and a fresh series may be perfectly alive. And no state is concluded from an absence without coverage: if the source was not observed completely over the expected window, the state is `unknown`, not `late`. "I saw nothing" never becomes "nothing happened": that is the difference between a client that stopped sending and a subscription that stopped collecting.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `state` | string | yes | `active`: the expected occurrence has arrived, or is not yet due. `late`: an occurrence is missing and the account was under observation. `inactive`: several consecutive occurrences are missing under complete observation; the series is no longer collecting, and we do not know why. `ended`: reserved for explicit terminal evidence. No accumulation of absences produces this state: a payment that stops may be a cancellation, a suspension, an expired card or a failed collection, and we do not guess which. `unknown`: we cannot conclude, and `reason` says why. Values: `active`, `late`, `inactive`, `ended`, `unknown`. |
| `reason` | string | — | — |
| `last_observed_on` | string · date \| null | — | — |
| `expected_next_window` | object \| null | — | A window, not a date. A salary lands around the last business days, a SEPA direct debit around the 5th. Returning an exact instant would be a precision we do not have, and you would believe it. |
| `expected_next_window.from` | string · date | — | — |
| `expected_next_window.to` | string · date | — | — |
| `expected_next_window.tolerance_days` | integer | — | The half-width of the window, in days. Larger = less precise. Comes from the anchor type, raised to the floor of the series family. |
| `expected_next_window.anchor` | string | — | The anchor type that produced the window. |
| `expected_next_window.widened_to_family_floor` | boolean | — | `true` if the tolerance was raised to the family floor (5 days for a subscription, 6 for a salary, 7 otherwise). |
| `consecutive_misses` | integer \| null | — | The counter only advances under complete coverage. Absent when nothing is expected, which is not the same ignorance as "we were not looking". |
| `observation_coverage` | string | — | `not_required`: no due date has closed, nothing had to be observed. `complete`: every source of the series was covered over the expected window; a miss is a miss. `incomplete`: at least one source was not; the absence proves nothing, `state` is `unknown`. `unknown`: the sources are unknown or too numerous to be checked. Values: `not_required`, `complete`, `incomplete`, `unknown`. |
| `cadence_strength` | number \| null | — | The same number as `cadence_detail.strength`, returned here so that the lifecycle can be read on its own. It is not a second score. |
| `calendar_anchor` | any | — | The same object as `calendar_pattern` (class `anchor_stability`), returned here for the same reason; `{ type: unknown }` when no anchor was inferred. Absent when no occurrence is expected. duplicate_of: `series[].calendar_pattern`; it is not a second score. |
| `evidence` | array<object> | — | Present for `ended`: the terminal evidence that produced it. |
| `evidence[].code` | string | — | — |
| `identity_status` | string \| null | — | Present only when the identity of the series has been corrected, to say why `state` is `unknown`: this is not economic ignorance, it is that the verdict now belongs to the successors. The full identity state is read in `identity`, which is an axis in its own right; this field is only a pointer, and it never takes the value `current`. Values: `superseded`, `null`. |
| `policy_version` | string | yes | — |
| `calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |

## Acting on each state

A handler dispatches on `state` without confusing `unknown` with `ended`:

```ts
const response = await fetch("https://api.noryo.app/transactions/v1/enrichments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NORYO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    descriptor: "PRLV SEPA NETFLIX INTERNATIONAL",
    direction: "outgoing",
    occurred_at: "2026-09-05T03:10:00+02:00",
    amount: { value: "13.49", currency: "EUR" },
    account_hint: { iban: "FR7630006000011234567890189" },
  }),
});
const enrichment = await response.json();

for (const series of enrichment.series ?? []) {
  const lifecycle = series.lifecycle ?? {};
  switch (lifecycle.state) {
    case "active":
      break; // nothing to do
    case "late":
    case "inactive":
      break; // the account was observed: the absence is a fact
    case "ended":
      break; // terminal evidence exists: read lifecycle.evidence
    default:
      if (lifecycle.identity_status === "superseded") {
        // follow series.identity.superseded_by
      } else if (lifecycle.observation_coverage === "incomplete") {
        // declare coverage for the account
      } else {
        // read lifecycle.reason
      }
  }
}
```

Three rules are enough. Never treat `unknown` as an ending: `reason`, `observation_coverage` and `identity_status` tell you which of the three cases applies. Do not derive your own `ended` by counting absences: the API does not, for the reasons given above. Finally, compare `cadence_strength` only between series of the same cadence, and keep `policy_version` next to any state you store: `calibration` is always `non_calibree`.

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/series-lifecycle
