Skip to content

Calendar definition format, spec version 1

View as Markdown

This page is a copy of spec/definition-v1.md in the repository, which is the source of truth.

Status: draft for fiscalclock 0.1.0. Last reviewed: 31 Oct 2026.

A calendar definition is one JSON object that describes how a fiscal year is bounded, subdivided, divided into weeks and named. The library computes every answer from this object and holds no built-in calendars.

This document is normative for the parts that fiscalclock 0.1.0 implements. Parts of the format that are designed but not yet implemented are listed in Reserved for later releases; a definition that uses them is rejected with UNSUPPORTED_FEATURE, so it never produces a silent wrong answer.

The key words “must”, “must not” and “may” are used as in RFC 2119.

Field Type Required Meaning
spec_version integer yes Must be 1. Any other integer is rejected with UNSUPPORTED_SPEC_VERSION, and that is then the only error reported, because the rest of the definition may follow another version’s rules.
$schema string no Ignored by the engine and excluded from the canonical form.
year object yes How the year is bounded.
week object yes How weeks run.
subdivision object yes How the year is split into quarters and periods.
naming object yes How years and periods are numbered and labelled.
exceptions object no Reserved. Any value is rejected with UNSUPPORTED_FEATURE in 0.1.0.

Unknown fields at any level are rejected with UNKNOWN_FIELD. A missing required field is rejected with MISSING_FIELD. A value of the wrong JSON type is rejected with INVALID_TYPE.

An object that repeats a key is rejected with INVALID_JSON at path "". RFC 8259 leaves the meaning of a repeated key to the parser, so a definition with two year blocks has more than one reading.

null for an optional field without a default (short_month, century_pivot) means the field is absent, so the canonical form, which writes them as null, validates again. null for a reserved field (exceptions, eras) is still UNSUPPORTED_FEATURE.

Weekday values are the lower-case English names monday … sunday. Month values are integers 1–12.

Field Type Required Meaning
start.month integer 1–12 yes Month in which every fiscal year starts.
start.day integer 1–31 yes Day of that month.
short_month "clamp" or "next" when start.day ≥ 29 What to do when the day does not exist in a month. clamp uses the month’s last day; next uses the first day of the following month.

The fiscal year that starts in calendar year Y begins on (Y, start.month, start.day), adjusted by short_month if that day does not exist. It ends the day before the next fiscal year begins.

  • start.day ≥ 29 without short_month is rejected with SHORT_MONTH_POLICY_MISSING.
  • short_month when start.day ≤ 28 is rejected with INVALID_VALUE, because it would never apply.

short_month exists only on fixed_date years, and the canonical form writes it as null there when it is absent.

Field Type Required Meaning
anchor.edge "end" or "start" yes Whether the rule fixes the year’s last day or its first day.
anchor.month integer 1–12 yes Month of the target date.
anchor.day integer 1–28 or "last" yes Day of the target date. "last" is the month’s last day.
anchor.rule "last", "first" or "nearest" yes last: the latest qualifying weekday on or before the target date. first: the earliest on or after it. nearest: the one closest to it (never a tie, because it is at most three days away).

The anchor has no weekday field. A week-based year consists of whole weeks, so its first day is always week.starts_on and its last day is always the weekday before week.starts_on. The qualifying weekday for edge: "end" is therefore the day before week.starts_on, and for edge: "start" it is week.starts_on itself. This removes the possibility of a year-end weekday that disagrees with the week definition.

For each calendar year Y, the target date is (Y, anchor.month, anchor.day) and the rule gives one boundary date B(Y).

  • With edge: "end", the fiscal year with anchor year Y runs from B(Y − 1) + 1 day to B(Y) inclusive.
  • With edge: "start", it runs from B(Y) to B(Y + 1) − 1 day inclusive.

Every such year has 364 or 371 days. A 371-day year has a 53rd week, called the extra week.

anchor.day above 28 is rejected with INVALID_VALUE, with a hint to use "last", because days 29–31 do not exist in every month in every year.

Examples:

  • The NRF 4-5-4 calendar ends on the Saturday nearest 31 January: {"edge": "end", "month": 1, "day": "last", "rule": "nearest"} with week.starts_on: "sunday".
  • Apple’s fiscal year ends on the last Saturday of September: {"edge": "end", "month": 9, "day": "last", "rule": "last"} with week.starts_on: "sunday".
  • The ISO 8601 week-numbering year starts on the Monday nearest 1 January: {"edge": "start", "month": 1, "day": 1, "rule": "nearest"} with week.starts_on: "monday". Its years must be numbered with year_number: "majority" (see Year numbers must be unique).
Field Type Required Meaning
starts_on weekday yes First day of every week at every level.
numbering "contains_start" yes How week 1 is chosen. "first_full" is reserved and rejected with UNSUPPORTED_FEATURE in 0.1.0.

numbering has no default, because organisations differ.

  • In a week_based year, week 1 starts on the year’s first day and every week has 7 days. Weeks are numbered 1–52, or 1–53 in a year with an extra week.
  • In a fixed_date year with contains_start, week 1 runs from the year’s first day to the day before the next starts_on, so it may have 1–7 days. Each later week starts on starts_on. The last week ends on the year’s last day and may also be partial. A year has 53 or 54 weeks.

Valid only inside a fixed_date year. Inside a week_based year it is rejected with UNSUPPORTED_COMBINATION.

Field Type Required Meaning
month_start_day integer 1–31 yes Day on which each fiscal month (period) starts. Must equal year.start.day, otherwise SUBDIVISION_YEAR_MISMATCH.
quarters array of positive integers yes Number of periods in each quarter, in order. Must sum to 12, otherwise QUARTERS_SUM_MISMATCH.

Period k (1–12) starts on month_start_day of the k-th month counted from year.start.month, adjusted by year.short_month when that day does not exist. It ends the day before period k + 1 starts, and period 12 ends on the year’s last day.

Valid only inside a week_based year in 0.1.0. Inside a fixed_date year it needs stub, which is reserved, so it is rejected with UNSUPPORTED_COMBINATION.

Field Type Required Meaning
periods array of 12 or 13 positive integers yes Weeks in each period. Must sum to 52, otherwise WEEKS_SUM_MISMATCH. Any other length is INVALID_VALUE.
quarters array of positive integers yes Number of periods in each quarter. Must sum to the length of periods, otherwise QUARTERS_SUM_MISMATCH.
extra_week object yes Where the 53rd week goes. Missing is EXTRA_WEEK_MISSING, because every week-based rule produces 53-week years (71 in every 400 years).
extra_week.period integer yes The period that gains the extra week as its last week. Outside 1 … length of periods is EXTRA_WEEK_OUT_OF_RANGE.

In a 53-week year, period extra_week.period has one more week than periods says, and the extra week is that period’s last week.

Quarter q consists of the periods given by quarters[q − 1], taken in order. A quarter’s length comes only from its periods. No rule assumes 13-week quarters.

Field Type Required Meaning
default string yes Name of the scheme used for coordinates and for format when no scheme is given. Must be a key of schemes, otherwise DEFAULT_SCHEME_MISSING.
schemes object yes One or more named schemes. Must not be empty (INVALID_VALUE). Scheme names match ^[a-z][a-z0-9_]{0,31}$, otherwise INVALID_VALUE.

Each scheme:

Field Type Required Meaning
year_number "start", "end" or "majority" yes Number a fiscal year by the calendar year of its first day, of its last day, or that holds more than half of its days. "table" is reserved (UNSUPPORTED_FEATURE).
offset integer −1,000,000,000 to 1,000,000,000 no, default 0 Added to the year number, for example 1 to label each fiscal year by the following calendar year. Outside the range is INVALID_VALUE, so that every year number fits a 32-bit integer.
century_pivot integer 100–9999 see below Resolves two-digit years when parsing. A two-digit value yy means the unique year in [century_pivot − 99, century_pivot] whose last two digits are yy.
templates object yes Label templates keyed by level: year, quarter, period, week. At least one. Unknown keys are UNKNOWN_FIELD.
eras any no Reserved (UNSUPPORTED_FEATURE).

A template is literal text with placeholders.

Placeholder Value Allowed in
{yyyy} Year number, zero-padded to 4 digits every level
{yy} Last two digits of the year number every level
{yy_next} Last two digits of the year number plus one every level, only together with {yyyy} or {yy}
{q} Quarter index, unpadded (no leading zero) quarter
{pp} Period index, zero-padded to 2 digits period
{ww} Week index, zero-padded to 2 digits week
  • Every template must contain {yyyy} or {yy}, so that a label identifies its year. {yy_next} alone is not enough. Otherwise INVALID_TEMPLATE.
  • A quarter, period or week template must contain its index placeholder ({q}, {pp} or {ww}), otherwise its labels cannot tell the periods of one year apart. Otherwise INVALID_TEMPLATE.
  • An unknown placeholder, an unbalanced brace, or a placeholder used outside the levels shown is INVALID_TEMPLATE.
  • A template that contains {yy} but not {yyyy} needs century_pivot, otherwise CENTURY_PIVOT_MISSING. The pivot resolves {yy}, and {yy_next} is then checked against it.
  • Year numbers above 9999 or below 1 after offset cannot be formatted with {yyyy}; format returns OUT_OF_RANGE.

Every scheme must give consecutive fiscal years consecutive numbers across the whole supported range. Validation computes the number of every fiscal year from the first to the last fiscal year inside the supported range, under every scheme. It does this only once the definition is otherwise valid, because the numbers depend on the year boundaries. It rejects:

  • YEAR_NUMBER_COLLISION when two fiscal years get the same number or a number is skipped. This happens with "start" or "end" when the year boundary can fall on either side of 1 January. For example, ISO week years numbered by "start" give both the year starting Monday 1 January 2018 and the year starting Monday 31 December 2018 the number 2018. details.years lists up to five of the year numbers, offset included, that are given twice or skipped, and the hint suggests a year_number that works.
  • MAJORITY_TIE when "majority" is used and some fiscal year has exactly half of its days in each of two calendar years. details.years lists up to five of the calendar years in which a tied fiscal year starts. A scheme with a tie is not also reported for collisions.

The path is naming.schemes.<name>.year_number.

Every period has the coordinates (level, year, index). year is the year number under the default scheme, including its offset. index is 1-based within the fiscal year and is null for the year itself.

The public API covers fiscal years that lie wholly between 0001-01-02 and 9999-12-30. A date in, or a request for, any other fiscal year returns OUT_OF_RANGE. This keeps every end_exclusive representable in Python, where date(9999, 12, 31) + timedelta(days=1) raises OverflowError.

The canonical form is the definition after parsing, serialised as JSON with:

  • $schema removed;
  • optional fields with a default written out (offset: 0), and optional fields without a default written as null (short_month, century_pivot); template levels that are absent stay absent;
  • object keys sorted by their UTF-8 bytes at every level;
  • no insignificant whitespace, integers in plain decimal, strings with the minimal JSON escapes: \", \\, \b, \f, \n, \r, \t, other characters U+0000 to U+001F as \u00xx with lower-case hexadecimal digits, and every other character, including non-ASCII, written as UTF-8.

calendar_id is the first 16 lower-case hexadecimal characters of the SHA-256 digest of the canonical form’s UTF-8 bytes. Two definitions that differ only in key order, whitespace, $schema or written-out defaults have the same calendar_id.

These parts of the format are designed in the research proposal but not implemented in 0.1.0. Each is rejected with UNSUPPORTED_FEATURE and a hint naming the planned release.

Field or value Planned
year.type: "table" 0.2
subdivision.type: "anchored" 0.2
subdivision.stub 0.2, after ADR-0024
subdivision.special_periods 0.2
extra_week.standalone, extra_week.by_year 0.2
week.numbering: "first_full" 0.2
naming.schemes.*.year_number: "table", eras 0.2
exceptions (overrides, history, transitions) 0.2