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. The JSON Schemas under schema/ (JSON Schema draft 2020-12) define the structural syntax; this document defines the language — its syntax and its semantics. 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.0",
"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
  • 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
  • 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
  • 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 changes: additions to closed sets (new vocabulary, atoms, or fields) that leave the meaning of every existing document unchanged. An implementation of 1.y′ accepts documents written against 1.y, where y′ > y, with the same meaning
  • 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

The first public version is 1.0.

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 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 with no upper bound (["every", 1, "day"] = every day from the from date). 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"]
  • 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

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
  • 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
  • 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 with no upper bound — 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
  • 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 order in which they combine. The order defines the observable result only — an implementation may compute it however it likes, as long as the outcome is identical.

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

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)

The following constraints are validated by implementations in addition to structural JSON Schema validation. Some may be expressible in JSON Schema, but they are defined here as semantic validation rules:

  • Resolvability of every name: a name used in the document is either a date_sets entry or declared under resolvers, no name is both, and every declared name is bound by the host
  • Presence of the calendar entries required by the calendar vocabulary in use
  • start < end for every time window, and non-overlap between windows (half-open, so touching is legal)
  • Every date literal is a real date (2026-02-30 is well-formed but invalid); the date part of from / until likewise
  • The resolved instant of from is strictly earlier than the resolved instant of until
  • Presence of from in a schedule that uses ["every", N, "day"] (cross-field constraints are outside the schema’s reach; the from of the interval every is required by the schema as well)
  • Existence of the timezone name in the IANA Time Zone Database (as available to the implementation); fixed-offset strings are rejected

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