# Cycle de vie d'une série

> Ce que la série traverse maintenant, avec les cinq états de lifecycle.state, la couverture d'observation qui autorise à conclure d'une absence, et le renvoi identity_status après une correction d'identité.

## Trois axes, jamais mélangés

Une `series` rendue par l'API porte trois blocs qui répondent à trois questions différentes, et qu'il ne faut pas confondre.

| Axe | Champ | Question | Valeurs |
|---|---|---|---|
| Établissement | `status` | À quel point Noryo croit-il que c'est une série ? | `emerging`, `established`, `uncertain`, `broken` |
| Cycle de vie | `lifecycle.state` | Que lui arrive-t-il maintenant ? | `active`, `late`, `inactive`, `ended`, `unknown` |
| Identité | `identity.status` | Son nom est-il toujours le bon ? | `current`, `superseded` |

Une série très bien établie peut avoir cessé de prélever ; une série encore `emerging` peut être parfaitement vivante. Le premier axe est décrit dans [Séries récurrentes](https://developer.noryo.app/fr-FR/transaction-intelligence/recurring-series), le troisième dans [Scission, fusion, supersession](https://developer.noryo.app/fr-FR/transaction-intelligence/split-merge-supersession). Cette page traite du deuxième.

## Les cinq états

Dans le bloc `lifecycle`, seuls `state`, `policy_version` et `calibration` sont requis. `state` prend l'une des valeurs suivantes, et son sens est exact.

| `state` | Sens |
|---|---|
| `active` | L'échéance attendue est arrivée, ou n'est pas encore échue. |
| `late` | Une échéance manque, et le compte était observé. |
| `inactive` | Plusieurs échéances consécutives manquent, sous observation complète. La série ne prélève plus, sans que Noryo sache pourquoi. |
| `ended` | Réservé à une preuve terminale explicite. Aucune accumulation d'absences ne produit cet état. |
| `unknown` | Noryo ne peut pas conclure, et `reason` dit pourquoi. |

`late` et `inactive` exigent l'un comme l'autre que le compte ait été observé : ils ne se déduisent jamais du seul silence. `ended` ne se déduit pas non plus d'un silence, même long : un prélèvement qui s'arrête peut être une résiliation, une suspension, une carte expirée ou un échec de prélèvement, et l'API ne devine pas laquelle. Quand `ended` est rendu, `evidence` porte la preuve terminale qui l'a produit.

> **Note** `unknown` est un résultat, pas une panne : `reason` en donne le motif en une phrase.

## Une absence ne prouve rien sans couverture

Le cycle de vie repose sur une distinction simple : « je n'ai rien vu » n'est pas « il n'y a rien eu ». Si votre intégration cesse d'envoyer les opérations d'un compte, chaque abonnement de ce compte semblera s'arrêter. Pour que l'absence d'une échéance ait un sens, Noryo doit savoir que le compte était observé, et observé complètement.

C'est le rôle de la [couverture d'observation](https://developer.noryo.app/fr-FR/transaction-intelligence/observation-coverage). Vous déclarez, par `POST /transactions/v1/coverage-windows`, que toutes les opérations d'un compte sur un intervalle vous ont été fournies (`completeness = complete`). `partial` et `unknown` n'y autorisent pas : dix opérations en octobre peuvent être dix sur cent. La déclaration est enregistrée telle quelle, avec `provenance = caller_asserted` : Noryo ne vérifie pas l'exhaustivité d'une banque.

Le bloc `lifecycle` rend ce qu'il a pu vérifier dans `observation_coverage` :

| `observation_coverage` | Sens |
|---|---|
| `not_required` | Aucune échéance close : rien n'avait à être observé. |
| `complete` | Chaque source de la série était couverte sur la fenêtre attendue. Un manque est un manque. |
| `incomplete` | Au moins une source ne l'était pas. L'absence ne prouve rien, et `state` est `unknown`. |
| `unknown` | Les sources sont inconnues, ou trop nombreuses pour être vérifiées. |

Tant que la couverture n'est pas `complete`, aucun état de manque (`late`, `inactive`) n'est conclu. Le compteur `consecutive_misses` n'avance que sous couverture complète ; il est absent quand rien n'est attendu, ce qui n'est pas la même ignorance que « nous ne regardions pas ».

> **Attention** La couverture se rattache à la série par le compte : `account_hint` (`iban` ou `external_account_id`) doit être le même sur la transaction enrichie et sur la fenêtre déclarée. Une transaction envoyée sans `account_hint` n'a aucune source à couvrir, et son `lifecycle.state` reste `unknown`.

## Une fenêtre, pas une date

Quand une échéance est attendue, `expected_next_window` en donne l'intervalle : `from`, `to`, et `tolerance_days`, la demi-largeur en jours. Un salaire tombe vers les derniers jours ouvrés, un prélèvement SEPA vers le 5 : un instant exact serait une précision que Noryo n'a pas. La largeur vient du type d'ancrage, rendu dans `anchor` ; `widened_to_family_floor` indique si la tolérance a été relevée au plancher de la famille de série.

Deux champs sont recopiés ici pour que le cycle de vie se lise seul : `cadence_strength` est le même nombre que `cadence_detail.strength` (classe `calendar_fill`), et `calendar_anchor` le même objet que `calendar_pattern` (classe `anchor_stability`). Aucun des deux n'est un score supplémentaire ni une probabilité : voir [Sémantique des scores](https://developer.noryo.app/fr-FR/transaction-intelligence/score-semantics). `calendar_anchor` vaut `{ "type": "unknown" }` quand aucun ancrage n'a été inféré, et il est absent quand aucune échéance n'est attendue. `last_observed_on` donne le dernier jour où une occurrence a été vue.

## Quand l'identité a été corrigée

Après une correction d'identité (`split` ou `merge` dans `superseded_by.reason`), l'ancien `series_id` reste lisible, mais son cycle de vie ne lui appartient plus : le verdict revient aux successeurs. Le bloc rend alors `state = unknown` avec `identity_status = superseded`. Ce champ n'est présent que dans ce cas et ne prend jamais la valeur `current`. Ce n'est pas une ignorance économique, c'est un renvoi : l'état d'identité complet, avec `superseded_by` et `supersedes`, se lit dans `identity`, et le déplacement de l'occurrence dans `membership`. La page [Scission, fusion, supersession](https://developer.noryo.app/fr-FR/transaction-intelligence/split-merge-supersession) détaille ces deux blocs.

## Lire le bloc

Une série mensuelle vivante, tirée du contrat :

```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"
}
```

La même série après une scission, lue par son ancien `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"
}
```

Le schéma complet :

**Schéma `SeriesLifecycle`**

Un axe différent de `status`. `status` dit à quel point nous croyons que c'est une série ; `lifecycle` dit ce qui lui arrive maintenant. Une série très bien établie peut avoir cessé de prélever, et une série fraîche peut être parfaitement vivante. Et aucun état ne se conclut d'une absence sans couverture : si la source n'était pas observée de façon complète sur la fenêtre attendue, l'état est `unknown`, pas `late`. « Je n'ai rien vu » ne devient jamais « il n'y a rien eu » — c'est la différence entre un client qui a cessé d'envoyer et un abonnement qui a cessé de prélever.

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `state` | string | oui | `active` : l'échéance attendue est arrivée, ou n'est pas encore échue. `late` : une échéance manque ET le compte était observé. `inactive` : plusieurs échéances consécutives manquent sous observation complète — la série ne prélève plus, sans que nous sachions pourquoi. `ended` : réservé à une preuve terminale explicite. Aucune accumulation d'absences ne produit cet état : un prélèvement qui s'arrête peut être une résiliation, une suspension, une carte expirée ou un échec de prélèvement, et nous ne devinons pas laquelle. `unknown` : nous ne pouvons pas conclure, et `reason` dit pourquoi. Valeurs : `active`, `late`, `inactive`, `ended`, `unknown`. |
| `reason` | string | — | — |
| `last_observed_on` | string · date \| null | — | — |
| `expected_next_window` | object \| null | — | Une fenêtre, pas une date. Un salaire tombe vers les derniers jours ouvrés, un prélèvement SEPA vers le 5. Rendre un instant exact serait une précision que nous n'avons pas, et vous la croiriez. |
| `expected_next_window.from` | string · date | — | — |
| `expected_next_window.to` | string · date | — | — |
| `expected_next_window.tolerance_days` | integer | — | La demi-largeur de la fenêtre, en jours. Plus grand = moins précis. Vient du type d'ancrage, relevé au plancher de la famille de série. |
| `expected_next_window.anchor` | string | — | Le type d'ancrage qui a donné la fenêtre. |
| `expected_next_window.widened_to_family_floor` | boolean | — | `true` si la tolérance a été relevée au plancher de la famille (5 jours pour un abonnement, 6 pour un salaire, 7 sinon). |
| `consecutive_misses` | integer \| null | — | Le compteur n'avance que sous couverture complète. Absent quand rien n'est attendu — ce qui n'est pas la même ignorance que « nous ne regardions pas ». |
| `observation_coverage` | string | — | `not_required` : aucune échéance close, rien n'avait à être observé. `complete` : chaque source de la série était couverte sur la fenêtre attendue — un manque est un manque. `incomplete` : au moins une source ne l'était pas — l'absence ne prouve rien, `state` est `unknown`. `unknown` : les sources sont inconnues ou trop nombreuses pour être vérifiées. Valeurs : `not_required`, `complete`, `incomplete`, `unknown`. |
| `cadence_strength` | number \| null | — | Le même nombre que `cadence_detail.strength`, rendu ici pour que le cycle de vie soit lisible seul. Ce n'est pas un second score. |
| `calendar_anchor` | any | — | Le même objet que `calendar_pattern` (classe `anchor_stability`), rendu ici pour la même raison ; `{ type: unknown }` quand aucun ancrage n'a été inféré. Absent quand aucune échéance n'est attendue. duplicate_of : `series[].calendar_pattern` — ce n'est pas un second score. |
| `evidence` | array<object> | — | Présent pour `ended` : la preuve terminale qui l'a produit. |
| `evidence[].code` | string | — | — |
| `identity_status` | string \| null | — | Présent uniquement quand l'identité de la série a été corrigée, pour dire pourquoi `state` vaut `unknown` : ce n'est pas une ignorance économique, c'est que le verdict appartient désormais aux successeurs. L'état d'identité complet se lit dans `identity`, qui est un axe à part entière ; ce champ n'est qu'un renvoi, et il ne prend jamais la valeur `current`. Valeurs : `superseded`, `null`. |
| `policy_version` | string | oui | — |
| `calibration` | string | oui | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |

## Réagir à chaque état

Un traitement aiguille sur `state` sans confondre `unknown` et `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; // rien à faire
    case "late":
    case "inactive":
      break; // le compte était observé : l'absence est un fait
    case "ended":
      break; // une preuve terminale existe : lisez lifecycle.evidence
    default:
      if (lifecycle.identity_status === "superseded") {
        // suivez series.identity.superseded_by
      } else if (lifecycle.observation_coverage === "incomplete") {
        // déclarez une couverture sur le compte
      } else {
        // lisez lifecycle.reason
      }
  }
}
```

Trois règles suffisent. Ne traitez jamais `unknown` comme une fin : `reason`, `observation_coverage` et `identity_status` disent lequel des trois cas s'applique. Ne dérivez pas votre propre `ended` en comptant les absences : l'API ne le fait pas, pour les raisons données plus haut. Enfin, comparez `cadence_strength` seulement entre séries de même cadence, et conservez `policy_version` à côté de tout état que vous stockez : `calibration` vaut toujours `non_calibree`.

---

Page canonique : https://developer.noryo.app/fr-FR/transaction-intelligence/series-lifecycle
