---
title: Specification
description: The Yrnk schedule DSL, version 1.1 — the normative definition of the language, its syntax and its semantics.
sidebar:
  order: 3
---

Yrnk is a JSON DSL for describing calendar-aware schedules. **Yrnk** is
short for Yarunoka: Yarunoka is the project, and Yrnk is the notation.
This document defines the language — its syntax and its semantics — and
is the source of authority. The JSON Schemas under
[`schema/`](https://github.com/yarunoka-dev/spec/tree/1.1/schema)
(JSON Schema draft 2020-12) are its supporting artifact: a
machine-readable rendition of what they can express. Implementations
must conform to both, and their agreement is verified by tests.

A Yrnk document is a **description of a set of occurrences** — points in
time, or whole days — and knows nothing about execution. "Should this fire", "last run at", and
"catch-up" do not exist in this language's vocabulary — they are the
caller's concern, expressed through the queries the caller asks.

## Conventions

How this document writes time boundaries. In interval notation, a
square bracket includes the endpoint and a parenthesis excludes it. In
words, the boundary words carry the same distinctions and are used with
exactly these meanings throughout:

| In words | In notation | Start | End |
|---|---|---|---|
| from a through b | [a, b] | included | included |
| from a until b | [a, b) | included | excluded |
| after a, through b | (a, b] | excluded | included |
| after a, before b | (a, b) | excluded | excluded |

## Document model

A document is a JSON object with three layers:

- **Reading directives** — `version` and `timezone` declare *how to
  interpret* the document before any content is read
- **External requirements** — `resolvers` declares what the document
  leaves to its host. Optional: a document that resolves every name
  itself has nothing to declare
- **Content** — `calendar` (the definitions) and `schedules` (the
  expressions)

Beside the layers, the document and each schedule may carry the
**annotations** `label` and `description` — what this is, for humans.
The language never reads them (see the annotations section).

```json
{
  "label": "Company calendar",
  "version": "1.1",
  "timezone": "Asia/Tokyo",
  "resolvers": ["yasumi-jp"],
  "calendar": {
    "holidays": "yasumi-jp",
    "date_sets": { "founding-day": ["2026-10-01"] }
  },
  "schedules": [
    {"label": "Founding day", "days": ["founding-day"], "allday": true}
  ]
}
```

| Key | Required | Meaning |
|---|---|---|
| `version` | ✓ | The spec version this document is written against, as an `"x.y"` string. Implementations must reject versions they do not know rather than silently interpreting them |
| `timezone` | ✓ | The timezone in which every schedule is interpreted. **The document, not the host's default timezone or locale, is authoritative** (resolver-backed names additionally depend on the supplied bindings — see the resolvers section — and the wall-to-instant mapping follows the implementation's tz database) |
| `resolvers` | | The names this document leaves to its host to resolve (see below). Omitted when there are none |
| `calendar` | | The definitions part (see below) |
| `schedules` | ✓ | The list of schedules. **The list is an OR of complete schedules** (a bare object is not allowed) |
| `label` | | Annotation: one line saying what this document is (see the annotations section) |
| `description` | | Annotation: a longer note; LF is the one permitted line break |

- Unknown keys are an error (closed set — the same rule applies at the
  document, calendar, schedule, and times levels)
- `timezone` is an **IANA Time Zone Database name** (`Asia/Tokyo`,
  `UTC`). Fixed offsets (`+09:00`) are not accepted — a document anchored
  to UTC writes `"UTC"`. Whether a name exists is checked against the
  implementation's tz database. Zones with daylight-saving transitions
  are allowed. Wall-clock times that
  fall on a transition are resolved **per RFC 5545 §3.3.5** — a time that
  does not exist (a gap: the spring-forward hour, or the whole day a zone
  skips when it moves across the date line) is interpreted with the offset
  in effect before the transition, which pushes it forward in real time; a
  time that occurs twice (the fall-back overlap) counts only as its first
  occurrence
- Zone names are **borrowed vocabulary**: Yrnk takes the names from
  the IANA Time Zone Database, and nothing more. What a name denotes —
  which wall-clock time maps to which instant — is the
  implementation's answer, given through its tz database; this
  specification does not pick a release. Where an implementation
  relies on a tz database, differing releases give differing answers
  for the zones and periods whose offset rules were revised, so
  agreement on that resolution between implementations is not
  guaranteed
- Arrays appear in two roles, distinguished by position. **List positions**
  (`years` / `months` / `days`, a `times` list, `workweek`,
  `business_hours`, date lists, `resolvers`, `schedules`) hold
  enumerations: the array
  enumerates members, and for the date axes omission means no restriction
  on that axis. There is no scalar sugar — `"days": "mon"` and
  `"months": 2` are invalid; a single member is still written as a
  one-element array (so that the same value never has two spellings, and
  round-tripping is the identity). **Tuple positions** (`shift`, `if`, the
  ordinal and day-cycle tuples, both `every` forms, a window) hold
  fixed-arity tuples whose elements are read by position, as defined in
  their sections
- Dates follow the **proleptic Gregorian calendar**; years run 1–9999.
  Every date literal must denote a real date (`2026-02-30` is
  well-formed but invalid), and the date part of `from` / `until`
  follows the same rule
- Enumerations reject duplicate members. The date axes, a `times` list,
  `workweek`, `business_hours`, and `resolvers` must be non-empty when
  present, and `schedules` must be non-empty. Date lists **may** be empty
  — an explicit empty list is the statement that there are no such days
- Objects reject duplicate member names: a document in which any
  object — the document itself, a `calendar`, a schedule, a
  `date_sets` — writes the same member name twice is invalid. Names
  compare after escape resolution (`"timezone"` and `"\u0074imezone"`
  are the same name): JSON decides member equality on the resolved
  characters, never on the written bytes
- Empty objects are invalid too: a `calendar` and a `date_sets` must
  hold at least one entry when present. A document with no definitions
  omits the key rather than writing `{}`, so that "no definitions" has a
  single spelling — the rule `resolvers` already states for its list
- The whole DSL denotes a set of **occurrences**. An occurrence is either
  **timed** (an instant) or **all-day** (a whole day; time does not apply
  to it). The two kinds never merge: an all-day occurrence and a timed
  occurrence at 00:00 of the same day are distinct elements. Within a
  kind, when several schedules produce the same occurrence the OR
  contains it once, and a firing decision sees it once
- An all-day occurrence stands at the **start of its day** on the document
  timezone's clock. The day is chosen on the calendar; its start then
  resolves like any other wall-clock point: a midnight that occurs twice
  (the fall-back overlap) stands at its first occurrence, and where a
  zone skips a day outright the push crosses midnight and the occurrence
  stands on the day that follows — that resulting day is the one it
  denotes. Two calendar days whose starts resolve to the same instant are
  therefore one occurrence

## Versioning

The spec version is an `"x.y"` string, and the `version` field of a
document declares which spec version it is written against.

- **y is raised** for backward-compatible revisions. A y-raise may
  contain additions to closed sets (new vocabulary, atoms, or fields),
  determinations of behavior the previous version left undefined, and
  restrictions that bind only documents declaring the new version. All
  three keep the guarantee: an implementation of 1.y′ accepts documents
  written against 1.y, where y′ > y, with their meaning unchanged
- **x is raised** for breaking changes. Compatibility with documents
  written against a lower major version is not guaranteed
- An implementation must reject a document whose declared version it does
  not know

**Validity follows the declared version.** An implementation validates a
document under the rules of the version the document declares: a
document declaring `"1.0"` keeps 1.0's validity rules, however new the
implementation reading it. A restriction introduced by a later version
therefore never rejects an older document — it binds only documents
that declare the version that introduced it, or a newer one.

**Evaluation semantics are one.** The evaluation model applies as this
document states it to every accepted document, whatever version it
declares. A determination of behavior an earlier version left undefined
therefore reaches documents declaring that earlier version too. That
keeps the acceptance guarantee: what was defined stays as it was, and
what was undefined had no meaning to preserve.

**A serializer keeps the declared version.** Reading a 1.0 document and
writing it back yields a 1.0 document; round-tripping never upgrades.
Moving a document to a newer version is a separate, explicit migration
step — one this specification does not require implementations to
provide. What it provides instead is the correspondence below, which is
what migration tooling works from.

The first public version is 1.0. **1.0 is deprecated**: new documents
should declare 1.1. Implementations keep accepting 1.0 documents — the
acceptance obligation ends only at a major raise.

### Correspondence between 1.0 and 1.1

For each spelling that 1.1 no longer accepts, this listing states what
it meant in 1.0 and how the same meaning is written in 1.1. Migration
tooling works from this listing.

- **An empty `calendar` or `date_sets` object.** In 1.0,
  `"calendar": {}` and `"date_sets": {}` are valid and mean the same as
  omitting the key. 1.1 rejects both; the meaning is written by omitting
  the key. The removal cascades: omitting a `date_sets` that was the
  calendar's only entry leaves `"calendar": {}` behind, which 1.1
  rejects too, so that calendar is omitted as well
- **A day-cycle or sequence `every` count beyond its bound.** In 1.0
  the counts have no upper bound; under the closed date domain a count
  beyond the domain's width yields the anchor point alone — an answer
  an implementation gives without huge-number arithmetic. 1.1 rejects
  such a count (the bounds and their derivation are in the day-cycle
  and sequence sections). The same meaning — a single occurrence at the
  anchor — is written with an `until` that ends the range before the
  second point

## Annotations — label and description

The document and each schedule may carry two annotation fields:
`label`, a single line of at most 100 characters, and `description`,
of at most 1,000 characters in which LF is the one permitted line
break. The caps are generous starting points rather than derived
limits — raising them is a compatible change, lowering them would not
be.

**Annotations are inert.** Their values take no part in the language:
they must not affect validation of the rest of the document,
evaluation, or the answer to any query — two documents that differ
only in their annotations denote the same set of occurrences. An
implementation preserves them unmodified through parsing and
serialization, hands them to its caller as written, and must not
branch on their content.

**Annotations are not identifiers.** Nothing requires a label to be
unique, and nothing in the language refers to a schedule by it.
Identity — keys, deduplication, cross-references — is the caller's
concern, like everything else about how documents are stored.

The value is text meant for human eyes, and the character rules
protect exactly that reading: control characters are forbidden (in
`description`, LF is the single exception), and so are the invisible
characters that can make a reader see something other than what is
written — the zero-width space, the word joiner, the byte-order mark,
and the bidirectional embedding, override, and isolate controls.
ZWJ/ZWNJ and the bidirectional marks remain legal: emoji sequences and
several scripts cannot be written without them. An annotation must
contain at least one non-whitespace character — "no annotation" is
spelled by omitting the key, never by an empty string.

An annotation is destined for displays the language knows nothing
about — a web page showing the label, a log line quoting it. The
language guarantees only the rules above; escaping for the output
medium is the consumer's responsibility, as for any externally
supplied text.

## resolvers — what the document leaves to its host

A name the document does not define under `calendar.date_sets` is
resolved outside the document, by whatever the host binds to that name — a
**resolver**. `resolvers` is where the document says which names those
are.

```json
"resolvers": ["yasumi-jp", "company-closures"]
```

- **Every name that is used and not defined must be declared here.** A
  name that appears in the document, is not a `date_sets` entry, and is
  not listed is a document validation error; the language never leaves a
  name silently to the host. The list is therefore complete — what a host
  must bind is exactly what it says. That completeness is the point:
  nothing in the document means anything until its names resolve, so the
  requirement has to be readable from the document alone, before the
  bindings that reading it is meant to prepare exist
- **A declared name need not be used.** The declaration states what the
  document depends on, and a document may name a dependency it has not
  yet written a schedule for. An unused declaration costs the host a
  binding that is never asked for, and nothing else
- The value is an enumeration of names — non-empty, no duplicates. A
  document that leaves nothing to its host **omits the key** rather than
  writing an empty list, so that "requires nothing" has a single spelling
- A declared name is spelled like any other name — no reserved words,
  nothing shaped like a literal (see the calendar section) — and must not
  collide with a `date_sets` key

**This is how dynamic data enters a document** — a database, a holiday
computation — while the document keeps the *intent*: what the dates are
resolved by, rather than what they happened to be when it was written. A
document that declares resolvers is portable only together with its
bindings. The host's locale or default timezone never affects
interpretation, but a resolver-backed name does depend on what the host
binds it to, which is why the document states the dependency rather than
leaving it to be discovered.

How a host materializes a resolver — the call signature, and whether a
relevant range is communicated — is implementation API, outside this
language. Implementations validate that the host binds every declared
name, and that what a resolver yields is a list of date literals
(`YYYY-MM-DD`); a resolver that fails at call time is a host-side
runtime error, not a document validation error.

## calendar — the definitions

The calendar is the set of date and time-window definitions that schedules
refer to. Its top-level keys are a closed set of **reserved keys** (the
built-in definitions); under `date_sets` is an **open namespace** (the
user's own named date lists). A calendar contains wall-clock dates and times
only and defines no timezone of its own: the timezone is declared at the
document level, and a calendar — like the schedules — is written on that
premise.

```json
"calendar": {
  "holidays": ["2026-01-01", "2026-01-12"],
  "business_holidays": [],
  "business_days": [],
  "workweek": ["mon", "tue", "wed", "thu", "fri"],
  "business_hours": [["09:00", "12:00"], ["13:00", "18:00"]],
  "date_sets": {
    "founding-day": ["2026-10-01"],
    "closing-day": ["2026-12-29", "2026-12-30"]
  }
}
```

- **The built-in definitions are special**: `holidays` /
  `business_holidays` / `business_days` / `workweek` carry the layer-model
  semantics (below), and `business_hours` is the window list behind the
  `business_hour` vocabulary. `date_sets` entries take no part in the
  layers; such a name is a flat "membership in a set" and nothing more
- **A `date_sets` value is a list of date literals** — nothing else. This
  is where the document *contains* the dates it names; a name whose dates
  come from elsewhere is not written here but left to a resolver, so the
  entry never stands for another name. Windows cannot be named at all: a
  date set can be large and dynamic, so naming one is a real need, but a
  window is a short literal written in place where it is used, so an
  aliasing mechanism would buy nothing. The only shared window list is
  the built-in `business_hours`. A `date_sets` key follows the rules every
  name follows: it must not collide with reserved words and must not
  look like a literal (digits only, date-shaped, time-shaped)
- **Name references**: wherever a date list is expected, a **name** may be
  written instead of the array (`"holidays": "yasumi-jp"`). A date-list
  position accepts exactly two forms: an array of date literals, or a
  name. Names must not be date-shaped, so a bare date-shaped string
  (`"holidays": "2026-01-01"`) matches neither form and is invalid — it is
  not read as a one-date list. This is a distinction of kind, not scalar
  sugar
- **A name denotes a date set, and all names share one namespace.** A name
  is resolved one of two ways: **inside** the document, by a `date_sets`
  entry that carries the date list itself, or **outside** it, by a binding
  the host supplies for that name (a **resolver**). Which of the two a
  name is makes no difference to where it may be written — every date-list
  position takes a name of either kind, and so does the `days` axis. A
  `date_sets` key and a resolver name must therefore never be the same
  string. A name that is neither a `date_sets` entry nor declared under
  `resolvers` is a document validation error

## Schedule

```json
{"days": [25], "shift": ["prev", "or_same", "business_day"], "times": ["10:00"]}
```

The fields are `years` / `months` / `days` (the date axes), `shift` / `if`
(the date modifiers), `times` | `allday` | `every` (the time part —
**exactly one is required**), `from` / `until` (the validity range), and
the annotations `label` / `description` (inert — see their section; they
take no part in anything below).

The algebra has three tiers: alternatives within a date-axis array are
combined with OR, fields within one schedule are combined with AND, and
complete schedules within the `schedules` list are combined with OR.

A schedule that uses `times` or `allday` is a product of two orthogonal
planes: the **date plane** (`years` / `months` / `days`, filtered by
`if`, moved by `shift`) and the **time plane** (`times` | `allday`).
Days are selected with no knowledge of times, and times are laid on the
selected days without moving them. A schedule with a top-level `every`
is the third form — a from-anchored sequence of points that takes no
date axes at all (see its section).

### from / until — validity range

A boundary that clips the schedule's set of points to the half-open
interval **[from, until)**. It is **not a recurrence condition** — it
never interferes with how the daily points are laid out (the times grid,
the matching days); points outside the range simply do not exist.

```jsonc
// an hourly task starting 10:00 on 7/15
{"from": "2026-07-15 10:00", "every": [1, "hour"]}

// every Monday 10:00 from 8/1 onward
{"from": "2026-08-01 00:00", "days": ["mon"], "times": ["10:00"]}

// limited period (all of July)
{"from": "2026-07-01 00:00", "until": "2026-08-01 00:00", "times": ["09:00"]}
```

- The value has exactly one form: `"YYYY-MM-DD HH:MM"` (zero-padded, a
  single space — U+0020 — no seconds). A date-only `"YYYY-MM-DD"` is invalid — if
  omission meant 00:00, the same instant would have two spellings, so a
  range starting at the top of a day writes `00:00` explicitly. `24:00`
  does not exist in this position (write the next day's `00:00`; the
  interval being half-open makes that mean exactly "through that day")
- Interpretation uses the document `timezone`. A boundary on a transition
  resolves by the same rule as scheduled points (RFC 5545 §3.3.5): a wall
  time that does not exist (a gap) is pushed forward, and one that occurs
  twice (the fall-back overlap) stands at its first occurrence
- A point at `from` is included; a point at `until` is not (the same
  half-open convention as `between`)
- Each is independent (only `from`, only `until`, or both). With both,
  the resolved instant of `from` must be strictly earlier than the
  resolved instant of `until`. **Vocabulary that counts** (the
  `["every", N, "day"]` atom and the interval `every`) requires `from`
  (there is no way to start counting without it); otherwise both are
  optional
- A timed occurrence is clipped by its instant. An all-day occurrence is
  clipped by its day: it survives when that day overlaps the range at all,
  so with `from: "2026-07-14 12:00"` the all-day occurrence of 7/14 stays
  — the day is still partly inside. A day carries no time, so asking
  whether it starts before a boundary is asking a question it cannot
  answer

### Date axes

- `years` — integers (1–9999). A one-off event (a specific day of a
  specific year) is expressible
- `months` — integers (1–12)
- `days` — an enumeration of the atoms below

| Atom | Example | Meaning |
|---|---|---|
| Number (1–31) | `25` | the 25th of every month |
| Day name | `"mon"` | every Monday |
| Calendar vocabulary | `"holiday"` | the five layer-model words (below) |
| Ordinal tuple | `["3rd", "mon"]` / `["last", "fri"]` | the third Monday / last Friday of the month |
| End of month | `"last_day_of_month"` | the only month boundary that moves, so it is the one special word |
| Day-cycle tuple | `["every", 2, "day"]` | every N days (below) |
| Name | `"founding-day"` | a reference to a date set — a `calendar.date_sets` entry, or a name the host binds to a resolver |

- A day number a month does not have **simply does not match** — `[31]`
  in a 30-day month produces no occurrence, exactly as a `5th` tuple in a
  month without a fifth week does not. Evaluation is a predicate over the
  days a month really has, so a nonexistent day never rolls over: a date
  library asked for April 31st may quietly answer May 1st, and that day
  is not an occurrence
- A day number is therefore not a spelling of the end of the month —
  `[31]` reaches only the months that have a 31st. The end of the month
  is written `last_day_of_month`
- The ordinals are the six words `1st`–`5th`, `last`. In a month without a
  fifth week, a `5th` tuple simply does not match. A tuple is always
  written as an element of the enumeration (`{"days": [["3rd", "mon"]]}`)
- A date literal (`"2026-10-01"`) cannot be written directly in `days`.
  Give the date a name under `date_sets` and refer to it
- There are no negative day numbers (`-1` = end of month). The day before
  the end of the month is written `{"if": ["next", "last_day_of_month"]}`

### The day-cycle tuple — every N days

```jsonc
// every 2 days at 03:00 (7/14, 7/16, 7/18, …)
{"from": "2026-07-14 00:00", "days": [["every", 2, "day"]], "times": ["03:00"]}
```

- The matching days are **every Nth day counting the date of the
  schedule's `from` as day one** ({the from day, +N, +2N, …}). Days before
  the `from` date never match. Because it counts, **`from` is required**
- `from` is a validity start, not a recurrence condition — the day the
  validity starts is merely day one of the count, and the firing times are
  decided by `times` (the time part of `from` only clips the range: with
  `from` at 7/14 12:00 and `times` 03:00, 7/14 03:00 is out of range and
  the first point is 7/16 03:00)
- The count is an integer ≥ 1 (`["every", 1, "day"]` = every day from
  the `from` date). The maximum count is 3,652,058 — the largest count
  whose second matching day stays inside the date domain (see the
  evaluation model) when `from` sits at its lower end. The unit is fixed
  and explicit: `"day"` (as with the times `every`, the unit is never
  sometimes-written, sometimes-not). A future year cycle would have a
  syntactic home as `["every", 2, "year"]`
- A count beyond the bound makes the document **invalid** — the same
  kind of invalidity as `"months": [13]`, and deliberately not the fate
  of a `shift` walking out of the date domain, which leaves the document
  valid: a stray shift is one base day among many, and the document
  keeps meaning, while every over-bound count collapses to the same
  behavior — the `from` day alone — so rejecting it forfeits no
  expressiveness and frees implementations from huge-number arithmetic
- `years` / `months` / `if` only **filter** the matching days; the count is
  not reset (excluded days do not shift the cycle). `shift` moves each
  matching day as a base day
- Allowed only as an element of the `days` enumeration (not as a `shift`
  landing condition or an `if` condition)

### Calendar vocabulary — the layer model

A business day is not a weekday. Weekdays are determined by the calendar
alone; business days are decided by an organization. The decision consults
the calendar's layers top-down with early return:

```text
business_days      top layer: "we work this day" — overrides everything below
business_holidays  the organization's own closures (often built on holidays)
holidays           public holidays; closed by default
workweek           bottom layer: the weekly pattern that sets the default (omitted = Mon–Fri)
```

- `weekday` / `weekend` ask the **fixed calendar** and consult no
  definition: weekday is always Monday–Friday and weekend always
  Saturday–Sunday, regardless of `workweek` (with a Tue–Sat workweek,
  Monday is still a weekday — and also a business holiday). `holiday`
  asks the `holidays` list **alone** (putting a holiday into
  `business_days` makes it a working day, but it is still a holiday)
- `business_day` / `business_holiday` are **questions to the stacked
  conclusion** (an ordinary Saturday is in none of the lists, yet by the
  weekly pattern it matches `business_holiday`). `business_holiday` is
  the exact complement of `business_day`: every day is exactly one of
  the two. The decision procedure:

```text
business_day(date):
    if date ∈ business_days:      true     — the top layer wins
    if date ∈ business_holidays:  false
    if date ∈ holidays:           false
    otherwise:                    day-of-week(date) ∈ workweek
                                  (workweek omitted = mon–fri)

business_holiday(date) = not business_day(date)
weekday(date)          = day-of-week(date) ∈ {mon … fri}   (workweek plays no part)
weekend(date)          = day-of-week(date) ∈ {sat, sun}
holiday(date)          = date ∈ holidays
```
- The lists are independent and may overlap; overlaps are settled by the
  layer priority
- A document that uses `holiday` requires the `holidays` definition; one
  that uses `business_day` / `business_holiday` requires all three layers
  (`holidays` / `business_holidays` / `business_days`). **Using them
  without the required definitions is a document validation error** (never
  a silent "no match"). An
  explicit empty list is a legitimate statement that there are no such
  days
- Choosing vocabulary: when the meaning of a schedule must not depend on
  the reader's definition data (say, garbage collection that stops on
  public holidays), write it with the calendar/holiday words rather than
  the business words

### shift — rounding

Takes each base day selected by the `days` condition and moves it in a
fixed direction until a landing condition holds. The date version of
numeric rounding (floor / ceil).

```text
shift: [direction, "or_same"?, landing condition]
    direction: "prev" | "next"; landing condition: a day atom
    2 elements = exclusive (strictly before / after); 3 with "or_same" = inclusive
```

```jsonc
// payday: the 25th, moved earlier if it falls on a non-business day
// (if the 25th is a business day, that day itself)
{"days": [25], "shift": ["prev", "or_same", "business_day"], "times": ["10:00"]}
```

- Omitting `or_same` is **not a default — it is the other meaning** (the
  same distinction as java.time's `previous` / `previousOrSame`).
  Forgetting it in the payday rule produces the quiet bug of an occurrence
  on the 24th exactly in the months where the 25th is a business day
- Consecutive non-matching days land on the same day and collapse into a
  single match (Sat/Sun/Mon of a three-day weekend → all Friday)
- `years` / `months` / `days` select the **base day only**. The landing
  day is not bound by them and may move into an adjacent month or year (a
  December base day landing in January of the next year still counts)
- The maximum displacement of a shift is **366 calendar days**; with
  `or_same`, displacement zero (the base day itself) is tested first. A
  base day whose landing condition never holds within that range produces
  no occurrences — it does not invalidate the document. The edge of the
  date domain bounds the search the same way: a search that would leave
  it stops with no landing (see the evaluation model)

### if — filtering by the day itself or a neighbour

**shift moves the day; if filters without moving.**

```text
if: [direction?, "not"?, condition]
    direction: "prev" | "next" (omitted = the day itself); condition: a day atom
```

```jsonc
// last working day before a break
{"days": ["business_day"], "if": ["next", "business_holiday"], "times": ["09:00"]}

// skip holidays without moving the occurrence
{"days": ["mon"], "if": ["not", "holiday"], "times": ["09:00"]}

// Friday the 13th
{"days": [13], "if": ["fri"], "times": ["09:00"]}

// the day before the end of the month
{"if": ["next", "last_day_of_month"], "times": ["09:00"]}
```

Combined with `shift`, **`if` filters the base days first, then `shift`
moves what remains**.

### times / allday — the time part

- **A list = an enumeration of fixed times**: `{"times": ["09:00", "12:00"]}`
- **An object = a clock grid**: `{"times": {"every": [1, "hour"], "between": ["08:00", "20:00"]}}`
- **`"allday": true`** = a day-level event that carries no time

Semantics:

- `every` is a **clock grid** — a description of a row of clock positions
  ("on the hour", …); a late execution never moves future points. The
  count is an integer ≥ 1; the unit is `"hour" | "minute" | "second"`
  (singular, fixed). The maximum count is 24 for `"hour"`, 1,440 for
  `"minute"`, and 86,400 for `"second"` — one day's worth in each unit
- `between` is the **half-open interval [start, end)**. "Every hour from
  8:00 until 20:00" is the 12 points 8:00–19:00 and **20:00 is excluded**.
  The value is a window pair or `"business_hour"` (the window list of
  `calendar.business_hours`; multiple windows, e.g. with a lunch break,
  are allowed)
- **The grid anchors at the start of the window** (`["08:30", …]` gives
  8:30, 9:30, …). Omitted `between` = the whole day [00:00, 24:00). The
  grid is laid out per day and per window, carrying nothing over from the
  previous day or window: a 7-hour grid over the whole day is 00:00,
  07:00, 14:00, 21:00 every day, starting again at 00:00 the next day;
  with `business_hours` of 09:00–12:00 and 13:00–18:00, an hourly grid
  anchors at 09:00 and at 13:00
- The grid is enumerated on the local wall clock, then each point is
  resolved to an instant per RFC 5545 §3.3.5. When a gap folds
  several wall times onto one instant, the set contains that one point;
  a wall time that occurs twice in the overlap counts only as its first
  occurrence
- A document that uses `between: "business_hour"` requires
  `calendar.business_hours`; using it without that definition is a
  document validation error
- `"24:00"` is a token allowed only as a window end. Windows that cross
  midnight (start ≥ end) cannot be written
- The windows of a window list must not overlap (half-open, so touching
  is legal)
- Times are **zero-padded HH:MM, fixed** (`"0:00"` is invalid; seconds
  cannot be written)
- An `allday` occurrence is a **day-level occurrence: time does not apply
  to it**. It matches on the day alone and ignores the time. It is not
  `times: ["00:00"]` — a timed occurrence at 00:00 and
  an all-day occurrence of the same day are distinct occurrences and never
  collapse into one. Because it carries no time, every question about a
  range answers for it by **whether its day overlaps that range**, never
  by comparing a time within the day

### every (directly on a schedule) — an interval sequence from `from`

The third form, for intervals that do not decompose into days and times
(every 36 hours = 129600 seconds = 1.5 days, …). Mutually exclusive with
`times` / `allday`; written directly on the schedule.

```jsonc
// every 7 hours anchored at 7/17 10:00: 10:00, 17:00, 00:00 next day, 07:00, …
{"from": "2026-07-17 10:00", "every": [7, "hour"]}

// every 36 hours
{"from": "2026-07-14 00:00", "every": [36, "hour"]}
```

- The points are **from + k × interval** (k = 0, 1, 2, …); `from` is the
  first point. The row is laid out by wall-clock arithmetic and each point
  resolved per RFC 5545 §3.3.5. **It keeps counting across days — unlike
  the clock grid there is no per-day re-anchoring.** The row is not
  endless: it ends at the date domain, generating no point on a day past
  9999-12-31 (see the evaluation model)
- **`from` is required** (a sequence has no definition without its
  anchor). `until` is optional and clips [from, until) as everywhere else
- The unit is `"hour" | "minute" | "second"` (singular, fixed); the count
  is an integer ≥ 1. The maximum count is 87,649,415 for `"hour"`,
  5,258,964,959 for `"minute"`, and 315,537,897,599 for `"second"` — for
  each unit, the largest count whose second point stays inside the date
  domain when `from` sits at its lower end. The grid's one-day cap is a
  consequence of its per-day re-anchoring semantics and does not apply to
  a from-anchored sequence; what bounds this count is the date domain
- A count beyond its bound makes the document **invalid**, for the
  reason the day-cycle section states — every over-bound count collapses
  to the same behavior, the anchor point alone
- The unit `"day"` is invalid here. Whole-day cycles belong to the
  calendar vocabulary (the `["every", N, "day"]` atom × `times`), and
  `from` + `every` 48 hour is **not** a substitute for "every 2 days at
  03:00" (it chains the ringing time to the time of `from`, so the
  validity start and the firing time can no longer be chosen
  independently)
- Cannot be combined with `days` / `months` / `years` / `shift` / `if`
  (it is a sequence of points, not a product of matching days × times, so
  it does not take the date axes). If a real need appears, it will be
  considered later in the closed-set manner
- Because the arithmetic is on the wall clock, real elapsed time deviates
  at DST transitions (a 36-hour sequence is really 35/37 hours on a
  transition day, keeping the wall-clock row intact). "Exactly every N
  seconds of real time" remains unsupported (the same line as the
  unsupported relative intervals)

## Evaluation model

The sections above define each construct; this section fixes the domain
evaluation works in and the order in which the constructs combine. The
order defines the **observable result** only — an implementation may
compute it however it likes, as long as the outcome is identical.

### The date domain

The days evaluation works over form a closed set — the **date domain**:
0001-01-01 through 9999-12-31, in the proleptic Gregorian calendar of
the document model. The input side of the language already stays inside
it (`years` caps at 9999, and a date literal writes a four-digit year);
the domain closes the evaluation side to match. Every day evaluation
touches, and the day of every occurrence, lies in the domain.

The domain closes on **calendar days, read on the document timezone's
clock** — not on instants. An occurrence's instant may exceed the
domain by the zone offset (a timed occurrence at 9999-12-31 23:00 in a
UTC-11 zone is a year-10000 instant); that is accepted. The bound is on
the day, and the day is in the domain.

At the edges of the domain, evaluation ends rather than fails:

- **A recurrence generates only its intersection with the domain.** A
  sequence or a row of matching days that would continue past
  9999-12-31 — or before 0001-01-01 — ends there instead
- **A `shift` whose search leaves the domain finds no landing.** The
  base day produces no occurrences and the document stays valid,
  exactly as when the 366-day cap runs out
- **An `if` whose `prev` / `next` neighbour lies outside the domain
  fails the whole guard**, and it fails before `not` applies — "no such
  day" is not a falsehood for `not` to turn into a match

A well-formed query (see below) is answered on the domain: its
endpoints are **cut to the domain, on the same wall-clock axis**, and
only the overlap is evaluated. The bounds of the cut are the instants of 0001-01-01 00:00
and 10000-01-01 00:00 on the document timezone's clock, so an
occurrence whose instant exceeds the domain by the zone offset is not
lost. Whether there is an overlap is decided on the endpoints as
given: a query lying entirely outside the domain answers empty — never
an error. Only when the query overlaps the domain does an endpoint
beyond a bound move to that bound, so the cut cannot turn an outside
query into one standing at the boundary.

### The order of combination

For each schedule:

1. **Base-day selection** — the date axes select days: `years` AND
   `months` AND `days`, with the enumeration inside each axis combined by
   OR. A day-cycle atom counts its days from the date of the schedule's
   `from` (day one)
2. **`if` filters** — base days whose condition does not hold are
   removed; nothing moves
3. **`shift` moves** — each remaining base day moves in the given
   direction until the landing condition holds (with a maximum
   displacement of 366 calendar days; a base day that finds nothing
   produces no occurrences). Consecutive
   base days may land on the same day and collapse
4. **Time generation** — on each resulting day, `times` lays out its
   fixed times or its grid (anchored per day and per window), or `allday`
   produces the day-level occurrence. A schedule with a top-level `every`
   skips steps 1–4 and generates the from + k × interval sequence instead
5. **Wall-to-instant resolution** — every wall-clock point resolves to an
   instant per RFC 5545 §3.3.5 (gap: pushed forward; overlap: first
   occurrence only)
6. **Range clipping** — `from` / `until` resolve to instants by the same
   rule. A timed occurrence survives if its instant lies in
   [from, until); the comparison is between **instants**, never
   wall-clock values. An all-day occurrence survives if its **day
   overlaps** [from, until) — that is, if the range holds any instant of
   that day. A boundary written on a transition resolves like any other
   wall-clock point — a nonexistent wall time (a gap) forward, a repeated
   one (the fall-back overlap) to its first occurrence; no special
   clipping rule applies to either
7. **Union across schedules** — the document's set is the union of the
   schedules' occurrences, with duplicates collapsing within each kind
   (timed / all-day) as defined in the document model

### Query well-formedness

The judgment over a period and the enumeration each name two
endpoints, and both require **start ≤ end** — a period's previous run
must not lie after its "now", and an enumeration's a must not lie
after its b. The comparison is between the instants as given: nothing
is rounded, and equal means exactly equal. The check reads the
endpoints **before they are cut to the date domain** — cut first, a
reversed pair lying outside the domain would collapse into an empty
overlap and pass as a legal empty query.

A violation is a **malformed query** — an error of a kind distinct
from document invalidity. The document stays valid; the question is
the side that does not stand. A reversed period arises only from
broken caller state or from a clock that moved backwards, and an
empty answer would hide exactly that — which is why the answer is an
error, not false. How an implementation surfaces the error — an
exception, a response shape — is implementation API, outside this
language.

Equal endpoints are legal. Zero width is reached by the normal period
contract — each judgment's "now" is the next one's start, so a caller
asking twice within the same second must not be punished. The meaning
follows from each query's interval: a period over (t, t] holds no
instant and answers false; an enumeration over [t, t] answers with
what stands exactly at that point — the timed occurrence at that
instant, and the all-day occurrence of the day it falls in, all-day
first, per the existing order.

The judgment at a point takes a single instant. It has no endpoints
to order, and this section does not apply to it.

### Queries

A judgment at a point asks: is the given instant an occurrence? For a
timed occurrence the answer is instant equality — the given instant,
ignoring anything finer than a second (no scheduled point is finer),
equals the occurrence's instant. The comparison is between instants,
never wall-clock values. An all-day occurrence matches on the day alone:
the answer is yes for every instant whose local date — read in the
document timezone — is that day.

A judgment over a period asks: is there a scheduled point after the
previous run, through now? A point exactly at the start does not count —
it was the previous judgment's "now", already counted; a point exactly
at now counts in this judgment, not the next. Each judgment's "now"
becomes the next one's start, so every timed point is seen exactly once.
Within a period, an all-day occurrence counts when its day overlaps the
period — the same reading as everywhere else. A period that lies inside
one day therefore answers yes for that day's all-day occurrence, however
late in the day it is asked: a day is due for as long as it lasts. That
does not make it a timed occurrence at 00:00.

An enumeration asks: which occurrences lie from a through b? The answer
is the occurrence set cut to [a, b] — every timed occurrence whose
instant lies in the closed interval, and every all-day occurrence whose
day overlaps it. Timed occurrences are
answered as instants, all-day occurrences as dates; the two kinds stay
distinct, as everywhere, and the answer is in ascending order, an
all-day occurrence taking the start of its day as its place in the order.
When that coincides with a timed occurrence (a timed point at the day's
00:00), the all-day occurrence comes first: the day precedes the points
within it. Unlike
the period judgment — whose start is excluded because that instant was
the previous judgment's "now" — an enumeration has no previous window:
the caller names two instants, and both are part of what it names.
Adjacent windows that share a boundary instant therefore both contain a
point exactly on it; a caller that means to exclude a boundary moves it.

## Deliberately unsupported

Closed sets can be widened compatibly, so these are added if and when a
real need appears.

- **Year cycles** (true biennial). Unlike the day cycle
  (`["every", N, "day"]`), years do not fold into a day count because
  their lengths differ. A strict fortnight is `["every", 14, "day"]`.
  Many real-world "every other week" rules are in fact month-anchored —
  "1st and 3rd Friday" — which is a **different rule** (a month with five
  Fridays breaks the 14-day cadence) and is written as that form
- **Relative intervals** (N seconds since the last run). That is
  throttling, not a schedule (the caller's concern). The last run time
  appears only in the caller's question interval, never in the definition
  of the points
- **Computed dates anchored to a fixed date** ("20 years after that
  day"). Write the folded result; preserving the provenance is the
  producer's responsibility
- **Anything that crosses the date plane and the time plane**. The two
  planes are orthogonal by construction: no day selection can depend on a
  time, and no time can depend on how a day was selected ("two hours
  after the same time on the previous business day", "move to the next
  day when the time falls outside business hours" are not expressible).
  The orthogonality is a design decision, not an omission
- **Windows that cross midnight**, **per-weekday business hours**,
  **user-defined window names**, **definition macros** (names referring
  to names)

## Examples

Unless noted otherwise, the following values are complete schedule
objects, not complete Yrnk documents.

```jsonc
// the third Monday of every month at 10:00
{"days": [["3rd", "mon"]], "times": ["10:00"]}

// Mon–Fri, hourly from 8:00 until 20:00
{"days": ["mon", "tue", "wed", "thu", "fri"],
 "times": {"every": [1, "hour"], "between": ["08:00", "20:00"]}}

// 8:00 on the day before a break (= the last business day before closed days)
{"days": ["business_day"], "if": ["next", "business_holiday"], "times": ["08:00"]}

// every 600 seconds
{"times": {"every": [600, "second"]}}

// payday: the 25th, moved to the previous business day on days off
{"days": [25], "shift": ["prev", "or_same", "business_day"], "times": ["10:00"]}

// the same rule wearing its name (the label plays no part in evaluation)
{"label": "Payday transfer", "days": [25], "shift": ["prev", "or_same", "business_day"], "times": ["10:00"]}

// last business day of the month at 17:00
{"days": ["last_day_of_month"], "shift": ["prev", "or_same", "business_day"], "times": ["17:00"]}

// garbage collection: 1st and 3rd Friday, skipped on holidays
{"days": [["1st", "fri"], ["3rd", "fri"]], "if": ["not", "holiday"], "times": ["07:30"]}

// water bill: the 27th of even months, moved to the next business day on days off
{"months": [2, 4, 6, 8, 10, 12], "days": [27],
 "shift": ["next", "or_same", "business_day"], "times": ["09:00"]}

// a golden wedding (one specific day)
{"years": [2043], "months": [6], "days": [15], "allday": true}

// every 2 days at 03:00 (anchored 7/14)
{"from": "2026-07-14 00:00", "days": [["every", 2, "day"]], "times": ["03:00"]}

// medication every other day, morning and evening
{"from": "2026-07-14 00:00", "days": [["every", 2, "day"]], "times": ["08:00", "20:00"]}

// an hourly task starting 7/15 at 10:00
{"from": "2026-07-15 10:00", "every": [1, "hour"]}

// every 36 hours
{"from": "2026-07-14 00:00", "every": [36, "hour"]}

// value of `schedules`: 8:00 on working days, 10:00 on days off
[{"days": ["business_day"], "times": ["08:00"]},
 {"days": ["business_holiday"], "times": ["10:00"]}]
```
