Recurring series
What a series (series[]) is, why its identifier stays stable, and how to read status, cadence, calendar_pattern and history_coverage.
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
type | What it asserts |
|---|---|
recurring_payment | This payment comes back, nothing more: rent and a loan instalment are this too. |
subscription | A stronger claim, which needs its own signal; without it, the answer stays recurring_payment. |
recurring_income | Income that comes back: a pension, a benefit, rent received. |
salary_series | A 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
status | Reading |
|---|---|
emerging | Two occurrences make a series emerge. A single one, never. |
established | Three occurrences can establish it. |
uncertain | Not described by the contract beyond its name; its example shows it on an irregular_recurring cadence (cadence_detail.strength at 0). |
broken | Computed 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.type | What it describes |
|---|---|
fixed_day_of_month | Always around the same day. |
month_end | At month end, whatever the number of days. |
last_business_day | The last business day, a common case for salaries. |
business_day_adjusted | A nominal day moved off the weekend. |
fixed_weekday | Always the same day of the week. |
flexible_calendar_window | One occurrence per period, with no stable day. |
unknown | Nothing 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_monthandflexible_calendar_window.0forunknown. A 0.33fixed_day_of_monthand a 0.3flexible_calendar_windowcannot be compared, andflexible_calendar_windowchanges its rule with the cadence (a constant 0.3 when weekly,1 - etendue/10when monthly). - calibrationstring
Always
non_calibreein 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
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.
| Field | Meaning |
|---|---|
raw_days | Lifetime of the raw transaction; past this delay it is deleted and nothing brings it back. retention_limit_days is identical, kept for compatibility. |
derived_days | Lifetime of what we have learnt about a series, counted from its latest occurrence. Once it expires, a later occurrence opens a new series. |
observed_days | The span actually observed, from the first to the last known occurrence. |
sufficiency | One question only: can the cadence be believed? sufficient, limited or insufficient. |
first_occurrence_bounded | A 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.
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
}
}JSONSchema 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_paymentsays "this payment comes back", and nothing more: rent and a loan instalment are this too.subscriptionis a stronger claim and requires its own signal; without it, the answer staysrecurring_payment. Likewise,recurring_incomecovers a pension, a benefit and rent received;salary_seriesis 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.
brokenis 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_calibreein 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.
valueis a string or an integer depending on the code (occurrencescarries an integer). - codestringrequired
- valuestring | integer· nullable
- lifecycleSeriesLifecycle
An axis different from
status.statussays how strongly we believe this is a series;lifecyclesays 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 isunknown, notlate. "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, andreasonsays 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
trueif 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,stateisunknown.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(classanchor_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
stateisunknown: this is not economic ignorance, it is that the verdict now belongs to the successors. The full identity state is read inidentity, which is an axis in its own right; this field is only a pointer, and it never takes the valuecurrent.supersedednull - policy_versionstringrequired
- calibrationstringrequired
Always
non_calibreein 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
statusnorlifecycle.statussays how strongly we believe this is a series;lifecyclesays what is happening to it economically;identitysays 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 aseries_idis never rewritten or silently reused. When we correct, the old name remains, markedsuperseded, 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
statusissuperseded. - 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_idyou 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_idyou 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_idfor 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_atfield: 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, andirregular_recurringis better than a false monthly.strengthis 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_calibreein 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 givesexpected_next_windowits 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_monthandflexible_calendar_window.0forunknown. A 0.33fixed_day_of_monthand a 0.3flexible_calendar_windowcannot be compared, andflexible_calendar_windowchanges its rule with the cadence (a constant 0.3 when weekly,1 - etendue/10when monthly). - calibrationstring
Always
non_calibreein 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.
insufficientmeans "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_daysis 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_boundedhas 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 returnsfalse; a series discovered as transactions arrive returnstrue. - detector_versionstring
Further reading
Published on 2026-09-17 11:19 UTC (version 1).