Skip to content
yarunoka.dev

Specification

View Markdown

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/ (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.

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

A document is a JSON object with three layers:

  • Reading directivesversion and timezone declare how to interpret the document before any content is read
  • External requirementsresolvers declares what the document leaves to its host. Optional: a document that resolves every name itself has nothing to declare
  • Contentcalendar (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).

{
"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

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.

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

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

Section titled “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.

"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.

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.

"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
{"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).

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.

// 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
  • 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 1st5th, 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"]}
// 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)

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:

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:
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

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).

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
// 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

Section titled “if — filtering by the day itself or a neighbour”

shift moves the day; if filters without moving.

if: [direction?, "not"?, condition]
direction: "prev" | "next" (omitted = the day itself); condition: a day atom
// 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.

  • 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

Section titled “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.

// 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)

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 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.

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 clippingfrom / 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

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.

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.

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)

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

// 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"]}]