Specification
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.
Conventions
Section titled “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
Section titled “Document model”A document is a JSON object with three layers:
- Reading directives —
versionandtimezonedeclare how to interpret the document before any content is read - External requirements —
resolversdeclares what the document leaves to its host. Optional: a document that resolves every name itself has nothing to declare - Content —
calendar(the definitions) andschedules(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)
timezoneis 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, atimeslist,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": 2are 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, botheveryforms, 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-30is well-formed but invalid), and the date part offrom/untilfollows the same rule - Enumerations reject duplicate members. The date axes, a
timeslist,workweek,business_hours, andresolversmust be non-empty when present, andschedulesmust 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, adate_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
calendarand adate_setsmust 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 ruleresolversalready 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
Section titled “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
Section titled “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
calendarordate_setsobject. 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 adate_setsthat 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
everycount 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 anuntilthat ends the range before the second point
Annotations — label and description
Section titled “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
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_setsentry, 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_setskey
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
Section titled “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.
"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/workweekcarry the layer-model semantics (below), andbusiness_hoursis the window list behind thebusiness_hourvocabulary.date_setsentries take no part in the layers; such a name is a flat “membership in a set” and nothing more - A
date_setsvalue 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-inbusiness_hours. Adate_setskey 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_setsentry 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 thedaysaxis. Adate_setskey and a resolver name must therefore never be the same string. A name that is neither adate_setsentry nor declared underresolversis a document validation error
Schedule
Section titled “Schedule”{"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
Section titled “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.
// 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 writes00:00explicitly.24:00does not exist in this position (write the next day’s00: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
fromis included; a point atuntilis not (the same half-open convention asbetween) - Each is independent (only
from, onlyuntil, or both). With both, the resolved instant offrommust be strictly earlier than the resolved instant ofuntil. Vocabulary that counts (the["every", N, "day"]atom and the intervalevery) requiresfrom(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
Section titled “Date axes”years— integers (1–9999). A one-off event (a specific day of a specific year) is expressiblemonths— 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 a5thtuple 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 writtenlast_day_of_month - The ordinals are the six words
1st–5th,last. In a month without a fifth week, a5thtuple 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 indays. Give the date a name underdate_setsand 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
Section titled “The day-cycle tuple — every N days”// 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
fromas day one ({the from day, +N, +2N, …}). Days before thefromdate never match. Because it counts,fromis required fromis 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 bytimes(the time part offromonly clips the range: withfromat 7/14 12:00 andtimes03: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 thefromdate). The maximum count is 3,652,058 — the largest count whose second matching day stays inside the date domain (see the evaluation model) whenfromsits at its lower end. The unit is fixed and explicit:"day"(as with the timesevery, 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 ashiftwalking 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 — thefromday alone — so rejecting it forfeits no expressiveness and frees implementations from huge-number arithmetic years/months/ifonly filter the matching days; the count is not reset (excluded days do not shift the cycle).shiftmoves each matching day as a base day- Allowed only as an element of the
daysenumeration (not as ashiftlanding condition or anifcondition)
Calendar vocabulary — the layer model
Section titled “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:
business_days top layer: "we work this day" — overrides everything belowbusiness_holidays the organization's own closures (often built on holidays)holidays public holidays; closed by defaultworkweek bottom layer: the weekly pattern that sets the default (omitted = Mon–Fri)weekday/weekendask the fixed calendar and consult no definition: weekday is always Monday–Friday and weekend always Saturday–Sunday, regardless ofworkweek(with a Tue–Sat workweek, Monday is still a weekday — and also a business holiday).holidayasks theholidayslist alone (putting a holiday intobusiness_daysmakes it a working day, but it is still a holiday)business_day/business_holidayare questions to the stacked conclusion (an ordinary Saturday is in none of the lists, yet by the weekly pattern it matchesbusiness_holiday).business_holidayis the exact complement ofbusiness_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
holidayrequires theholidaysdefinition; one that usesbusiness_day/business_holidayrequires 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
Section titled “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).
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_sameis not a default — it is the other meaning (the same distinction as java.time’sprevious/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/daysselect 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.
times / allday — the time part
Section titled “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:
everyis 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 unitbetweenis 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 ofcalendar.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, …). Omittedbetween= 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; withbusiness_hoursof 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"requirescalendar.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
alldayoccurrence is a day-level occurrence: time does not apply to it. It matches on the day alone and ignores the time. It is nottimes: ["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, …);
fromis 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) fromis required (a sequence has no definition without its anchor).untilis 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 whenfromsits 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), andfrom+every48 hour is not a substitute for “every 2 days at 03:00” (it chains the ringing time to the time offrom, 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
Section titled “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
Section titled “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
shiftwhose 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
ifwhoseprev/nextneighbour lies outside the domain fails the whole guard, and it fails beforenotapplies — “no such day” is not a falsehood fornotto 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
Section titled “The order of combination”For each schedule:
- Base-day selection — the date axes select days:
yearsANDmonthsANDdays, with the enumeration inside each axis combined by OR. A day-cycle atom counts its days from the date of the schedule’sfrom(day one) iffilters — base days whose condition does not hold are removed; nothing movesshiftmoves — 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- Time generation — on each resulting day,
timeslays out its fixed times or its grid (anchored per day and per window), oralldayproduces the day-level occurrence. A schedule with a top-leveleveryskips steps 1–4 and generates the from + k × interval sequence instead - 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)
- Range clipping —
from/untilresolve 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 - 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Examples”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"]}]