Skip to content
Sandbox

Recurring series

What a series (series[]) is, why its identifier stays stable, and how to read status, cadence, calendar_pattern and history_coverage.

View as Markdown

A series (series[] on an enrichment) groups several occurrences that are economically linked over time, within your workspace: a subscription, rent, a salary, a pension. lifecycle and identity each have their own page: Series lifecycle and Split, merge, supersession.

A series is not a counterparty

A counterparty is the other end of a transaction; a series is a flow that comes back. One counterparty can carry several series: two distinct Netflix subscriptions are two series, with two series_id values. And a series never collapses into "every transaction with this counterparty".

A series is private: it lives in your workspace and never feeds the shared entity graph.

An identifier that does not depend on the latest occurrence

series_id derives from what does not change: the workspace, the series family, the counterparty, the direction and the currency. It depends neither on the latest occurrence nor on the current descriptor. One more occurrence does not create another series.

You can therefore store series_id. Only an identity correction replaces it; the old one remains readable, marked superseded under identity, and membership links what you had stored to what you now receive.

The four types

typeWhat it asserts
recurring_paymentThis payment comes back, nothing more: rent and a loan instalment are this too.
subscriptionA stronger claim, which needs its own signal; without it, the answer stays recurring_payment.
recurring_incomeIncome that comes back: a pension, a benefit, rent received.
salary_seriesA specialisation of recurring_income, not a synonym.

implies carries the more general type that a type specialises: a subscription implies recurring_payment. counterparty.semantics.recurring_model says what is usual in the counterparty's line of business, not what is true of this transaction: on its own it never concludes; series[].type is what decides. There is no subscription category: a subscription is a model of commercial relationship, not a kind of spending.

status: how much of a series it is

statusReading
emergingTwo occurrences make a series emerge. A single one, never.
establishedThree occurrences can establish it.
uncertainNot described by the contract beyond its name; its example shows it on an irregular_recurring cadence (cadence_detail.strength at 0).
brokenComputed at read time: a series becomes late through the mere passage of time, with no write taking place.

status says neither what is happening to the series now (lifecycle.state) nor whether its name is still the right one (identity.status).

A first occurrence opens no series: series[] is empty and relationships_policy.unresolved carries an entry with type = series, outcome and occurrences (single_occurrence and 1 in the contract's example, no_series_key and 0 in its unresolved example). The other outcomes are listed in the Reference.

Cadence and anchoring: two different questions

cadence says how often the event repeats: weekly, biweekly, monthly, quarterly, yearly, irregular_recurring or unknown. It is read on the calendar, not in bare days: 31 January and 28 February are one month apart. A 28-day recurrence is not monthly: its day moves back with every cycle, and it is filed under irregular_recurring. cadence_detail.strength is the share of expected cycles that carry an occurrence; 0 means "no calendar hypothesis retained", not "weak".

calendar_pattern answers a different question: where, within each period, the series tends to fall. A series can be squarely monthly while floating between the 1st and the 6th; without this block, monthly would read as "the same day every month".

calendar_pattern.typeWhat it describes
fixed_day_of_monthAlways around the same day.
month_endAt month end, whatever the number of days.
last_business_dayThe last business day, a common case for salaries.
business_day_adjustedA nominal day moved off the weekend.
fixed_weekdayAlways the same day of the week.
flexible_calendar_windowOne occurrence per period, with no stable day.
unknownNothing identifiable.

Public holidays are not modelled: a holiday shift is absorbed by the window's tolerance, not recognised. Anchoring is also what gives lifecycle.expected_next_window its width.

Schema CalendarPattern

Where, within each period, the series tends to fall: a different question from `cadence`. A series can be squarely monthly while floating between the 1st and the 6th: without this field, "monthly" would read as "the same day every month", which is false for most real series. It is also what gives `expected_next_window` its width: a direct debit always on the 5th justifies three days, a rent that floats justifies eight. Applying the same window to both would declare a perfectly normal series "late".

  • typestring

    fixed_day_of_month: always around the same day. month_end: at month end, whatever the number of days; a direct debit on 31 January, 28 February and 31 March is a single pattern. last_business_day: the last business day, a common case for salaries. business_day_adjusted: a nominal day moved off the weekend (Saturday → Friday, Sunday → Monday). We do not model public holidays: neither French, nor target, nor banking holidays. A holiday shift is absorbed by the window's tolerance, not recognised; claiming to know a calendar we do not have would be worse than ignoring it. fixed_weekday: always the same day of the week. flexible_calendar_window: one occurrence per period, with no stable day. unknown: nothing identifiable.

    fixed_day_of_monthmonth_endlast_business_daybusiness_day_adjustedfixed_weekdayflexible_calendar_windowunknown
  • nominal_dayinteger· nullable
  • weekdayinteger· nullable
  • day_rangearray<integer>· nullable
  • weekend_shiftsinteger· nullable
  • strengthnumber · 0–1

    A constant per anchor type, or a linear formula on the spread of days for fixed_day_of_month and flexible_calendar_window. 0 for unknown. A 0.33 fixed_day_of_month and a 0.3 flexible_calendar_window cannot be compared, and flexible_calendar_window changes its rule with the cadence (a constant 0.3 when weekly, 1 - etendue/10 when monthly).

  • calibrationstring

    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
Note

cadence_detail.strength (class calendar_fill) and calendar_pattern.strength (class anchor_stability) are not probabilities: each is only comparable between series of the same cadence, and of the same anchor type for the latter. See Score semantics.

Amounts and the next due date

amount_behavior does not require exact equality: an electricity bill varies, and so does a salary. kind is fixed, narrow_variable or broadly_variable; min_minor_units and max_minor_units are strings in minor units, never JSON numbers.

expected_next_at is optional, by design: it is absent until the series is established, and whenever the cadence is irregular. This is not a forecasting API.

What memory lets you conclude

history_coverage separates two memories.

FieldMeaning
raw_daysLifetime of the raw transaction; past this delay it is deleted and nothing brings it back. retention_limit_days is identical, kept for compatibility.
derived_daysLifetime of what we have learnt about a series, counted from its latest occurrence. Once it expires, a later occurrence opens a new series.
observed_daysThe span actually observed, from the first to the last known occurrence.
sufficiencyOne question only: can the cadence be believed? sufficient, limited or insufficient.
first_occurrence_boundedA different question: do we know when it started? first_occurrence_reason says why.

A purged transaction stays purged, but the series remembers that it happened: this is what makes a monthly cadence confirmable when January's transaction no longer exists.

insufficient means "our memory is too short to judge", never "there is no older occurrence". first_observed_on is the first day we observed the series, not the day it started (there is no started_at field); may_predate_observation is true when it may have begun before us.

Warning

A yearly series cannot become established within the observable window: status stays emerging. Read sufficiency and observed_days rather than concluding from occurrence_count alone.

match_strength and evidence

match_strength.value is not a probability: it is the share of five heterogeneous criteria present (corroborations.present over corroborations.possible). It is not the same number as relationships[].match_strength, and it is only comparable between series of the same detector_version. evidence[] lists the signals the series rests on (occurrences carries an integer).

Example

An established subscription series, an extract of the contract's example:

{
  "series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "type": "subscription",
  "implies": ["recurring_payment"],
  "status": "established",
  "cadence": "monthly",
  "cadence_detail": { "type": "monthly", "strength": 1, "calibration": "non_calibree" },
  "calendar_pattern": {
    "type": "fixed_day_of_month", "nominal_day": 5, "weekday": null,
    "day_range": [4, 6], "weekend_shifts": 0, "strength": 0.67, "calibration": "non_calibree"
  },
  "occurrence_count": 6,
  "amount_behavior": { "kind": "fixed", "min_minor_units": "1349", "max_minor_units": "1349", "currency": "EUR" },
  "first_observed_on": "2026-04-05",
  "last_observed_on": "2026-09-05",
  "may_predate_observation": true,
  "expected_next_at": "2026-10-05T00:00:00Z",
  "match_strength": { "value": 1, "calibration": "non_calibree", "corroborations": { "present": 5, "possible": 5 } },
  "evidence": [
    { "code": "stable_counterparty", "value": "entite:b83d1756-258c-4541-adc7-54eec83dbd3c" },
    { "code": "occurrences", "value": 6 },
    { "code": "cadence", "value": "monthly" },
    { "code": "amount_behavior", "value": "fixed" },
    { "code": "subscription_category" }
  ],
  "history_coverage": {
    "raw_days": 30, "derived_days": 400, "observed_days": 153, "retention_limit_days": 30,
    "occurrence_count": 6, "cadence": "monthly", "sufficiency": "sufficient",
    "first_occurrence_bounded": true
  }
}
JSON

Schema Series

Several occurrences economically linked over time, within this workspace. A private series never becomes a truth of the entity graph.

  • series_idstringrequired

    Stable. It derives 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.

    ^ser_[0-9a-f-]{36}$
  • typestringrequired

    recurring_payment says "this payment comes back", and nothing more: rent and a loan instalment are this too. subscription is a stronger claim and requires its own signal; without it, the answer stays recurring_payment. Likewise, recurring_income covers a pension, a benefit and rent received; salary_series is a specialisation of it, not a synonym.

    recurring_paymentsubscriptionrecurring_incomesalary_series
  • impliesarray<string>

    The more general type that this one specialises, if there is one.

  • statusstringrequired

    Two occurrences make a series emerge, three can establish it, a single one never. broken is computed at read time: a series becomes late through the mere passage of time, with no write taking place.

    emergingestablisheduncertainbroken
  • cadencestringrequired

    Read on the calendar, not in bare days: 31 January and 28 February are one month apart, and a salary on the last business day shifts.

    weeklybiweeklymonthlyquarterlyyearlyirregular_recurringunknown
  • occurrence_countintegerrequired
  • amount_behaviorobjectrequired

    Exact equality is not required: an electricity bill varies, and so does a salary. Amounts in minor units, as a string, never a JSON number.

  • kindstring
    fixednarrow_variablebroadly_variable
  • min_minor_unitsstring
  • max_minor_unitsstring
  • currencystring
  • first_seenstring · date-time
  • last_seenstring · date-time
  • expected_next_atstring · date-time· nullable

    Optional, and that is the point. Absent as long as the series is not established or the cadence is irregular: returning a date on the basis of two weak observations would pass it off as a forecast. This is not a forecasting API.

  • match_strengthobjectrequired

    This is not a probability: it is the share of corroborating signals present out of those this type can produce. And it is not the same number as relationships[].match_strength: same name, different denominator, different signals. Five heterogeneous criteria out of five: calendar cadence, fixed or narrow amount, at least three occurrences, known entity, specialised type. They only add up here.

  • valuenumber · 0.001–1required
  • 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
  • corroborationsobjectrequired
  • presentintegerrequired
  • possibleintegerrequired
    5
  • evidencearray<object>

    The signals the series rests on. value is a string or an integer depending on the code (occurrences carries an integer).

  • codestringrequired
  • valuestring | integer· nullable
  • lifecycleSeriesLifecycle

    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
  • identitySeriesIdentity

    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.

  • membershipSeriesMembership

    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
  • first_observed_onstring · date

    The first day WE Observed this series, not the day it started. A series may have been running for five years while we have only been observing it for three months. There is deliberately NO started_at field: we do not know it.

  • last_observed_onstring · date
  • may_predate_observationboolean

    true: our observation begins with the first known occurrence, so the series may have started before us and nothing will show it. false: the first known occurrence predates the start of our observation, so its beginning is observed, not assumed.

  • cadence_detailobject

    How often the event repeats, and nothing else. The cadence is deduced from the progression of calendar periods, not from gaps in days: three consecutive months are three consecutive months, whether the gaps are 28, 30 or 31 days. And a 28-day recurrence is not monthly: it falls once a month for eleven months, then twice, and its day moves back with every cycle. We do not call it monthly: this version has no cadence for it, and irregular_recurring is better than a false monthly. strength is the share of expected cycles that carry an occurrence. It is not a probability.

  • typestring
  • strengthnumber · 0–1· nullable

    0 = no calendar hypothesis retained, not "weak".

  • calibrationstring

    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
  • calendar_patternCalendarPattern

    Where, within each period, the series tends to fall: a different question from cadence. A series can be squarely monthly while floating between the 1st and the 6th: without this field, "monthly" would read as "the same day every month", which is false for most real series. It is also what gives expected_next_window its width: a direct debit always on the 5th justifies three days, a rent that floats justifies eight. Applying the same window to both would declare a perfectly normal series "late".

  • typestring

    fixed_day_of_month: always around the same day. month_end: at month end, whatever the number of days; a direct debit on 31 January, 28 February and 31 March is a single pattern. last_business_day: the last business day, a common case for salaries. business_day_adjusted: a nominal day moved off the weekend (Saturday → Friday, Sunday → Monday). We do not model public holidays: neither French, nor target, nor banking holidays. A holiday shift is absorbed by the window's tolerance, not recognised; claiming to know a calendar we do not have would be worse than ignoring it. fixed_weekday: always the same day of the week. flexible_calendar_window: one occurrence per period, with no stable day. unknown: nothing identifiable.

    fixed_day_of_monthmonth_endlast_business_daybusiness_day_adjustedfixed_weekdayflexible_calendar_windowunknown
  • nominal_dayinteger· nullable
  • weekdayinteger· nullable
  • day_rangearray<integer>· nullable
  • weekend_shiftsinteger· nullable
  • strengthnumber · 0–1

    A constant per anchor type, or a linear formula on the spread of days for fixed_day_of_month and flexible_calendar_window. 0 for unknown. A 0.33 fixed_day_of_month and a 0.3 flexible_calendar_window cannot be compared, and flexible_calendar_window changes its rule with the cadence (a constant 0.3 when weekly, 1 - etendue/10 when monthly).

  • calibrationstring

    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
  • history_coverageobjectrequired

    What we can see, and what we cannot. insufficient means "our memory is too short to judge", never "there is no older occurrence". And there are two memories, which must not be confused. raw_days (30) says how long the transaction remains readable; derived_days (400) says how long what we have learnt about a series outlives it. A purged transaction stays purged (this API never brings it back), but the series remembers that it happened. That is what makes a monthly cadence confirmable when January's transaction no longer exists. observed_days is what we actually saw: a client reading "monthly / sufficient" can check that the conclusion does not come from thirty days.

  • observed_fromstring · date
  • observed_tostring · date
  • raw_daysinteger

    Lifetime of the raw transaction. Past this delay it is physically deleted and nothing brings it back.

  • derived_daysinteger

    Lifetime of the series memory, counted from the latest occurrence. Without a new occurrence, the derived state is physically deleted when it expires, and a later occurrence opens a new series.

  • observed_daysinteger

    The span actually observed, from the first to the last known occurrence.

  • retention_limit_daysintegerrequired

    Kept for compatibility: identical to raw_days.

  • occurrence_countintegerrequired
  • cadencestring
  • sufficiencystringrequired

    Answers one question only: can the cadence be believed? Not "do we know when it started", which is the next field.

    sufficientlimitedinsufficient
  • reasonstring
  • first_occurrence_reasonstring

    Says why first_occurrence_bounded has the value it has.

  • first_occurrence_boundedboolean

    A different question from sufficiency, and one that used to be confused with it. true: our observation of this series begins with the first occurrence we know of, so it may have started before us, and nothing will ever show it. false: the first known occurrence predates the start of our observation, so the beginning is observed, not assumed. A history import returns false; a series discovered as transactions arrive returns true.

  • detector_versionstring

Further reading

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

Was this page helpful?