Skip to content
Sandbox

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.

View as Markdown

Three axes, never mixed

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

AxisFieldQuestionValues
EstablishmentstatusHow strongly does Noryo believe this is a series?emerging, established, uncertain, broken
Lifecyclelifecycle.stateWhat is happening to it right now?active, late, inactive, ended, unknown
Identityidentity.statusIs 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, the third in 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.

stateMeaning
activeThe expected occurrence has arrived, or is not yet due.
lateOne occurrence is missing, and the account was under observation.
inactiveSeveral consecutive occurrences are missing, under complete observation. The series is no longer collecting, and Noryo does not know why.
endedReserved for explicit terminal evidence. No accumulation of absences ever produces this state.
unknownNoryo 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 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_coverageMeaning
not_requiredNo occurrence has fallen due: nothing needed observing.
completeEvery source of the series was covered over the expected window. A gap is a gap.
incompleteAt least one source was not. The absence proves nothing, and state is unknown.
unknownThe 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. 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 page covers both blocks in detail.

Reading the block

A live monthly series, taken from the contract:

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

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

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

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.

  • statestringrequired

    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.

    activelateinactiveendedunknown
  • reasonstring
  • last_observed_onstring · date· nullable
  • expected_next_windowobject· nullable

    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.

  • fromstring · date
  • tostring · date
  • tolerance_daysinteger

    The half-width of the window, in days. Larger = less precise. Comes from the anchor type, raised to the floor of the series family.

  • anchorstring

    The anchor type that produced the window.

  • widened_to_family_floorboolean

    true if the tolerance was raised to the family floor (5 days for a subscription, 6 for a salary, 7 otherwise).

  • consecutive_missesinteger· nullable

    The counter only advances under complete coverage. Absent when nothing is expected, which is not the same ignorance as "we were not looking".

  • observation_coveragestring

    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.

    not_requiredcompleteincompleteunknown
  • cadence_strengthnumber· nullable

    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_anchorany

    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.

  • evidencearray<object>

    Present for ended: the terminal evidence that produced it.

  • codestring
  • identity_statusstring· nullable

    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.

    supersedednull
  • policy_versionstringrequired
  • calibrationstringrequired

    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.

    non_calibree

Acting on each state

A handler dispatches on state without confusing unknown with ended:

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
      }
  }
}
TypeScript

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.

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

Was this page helpful?