Summary#
A Schedule produces work on a recurring cadence. Each occurrence creates a row of the schedule's
Template — and that is the whole of what it does. A workflow whose Tracks and Initial (the field a workflow drives) names the same entity
then starts through the ordinary autostart path, so a schedule never mentions a workflow and needs no new wiring to
reach one.
Schedules are rows, not syntax. Moving a nightly job from 02:00 to 03:00 is an edit, not a deploy; one workflow can have several schedules; each tenant can have its own in its own zone; and a schedule can be paused, resumed and retired without touching the source.
Signature#
new Osyrin.Scheduling.Schedule {
Name = <string>, // how operators refer to it
Template = new <Entity> { … },// each occurrence creates a row of THIS, with these members set
Zone = <"IANA zone">, // the zone every local time below is measured in
EffectiveFrom = <DateTime>, // when it starts — and its PHASE ORIGIN
EffectiveUntil = <DateTime?>, // optional end, inclusive of the instant
Status = Active | Paused | Retired,
Overlap = Skip | Allow
}
new Osyrin.Scheduling.ScheduleRule { Schedule = <schedule>, Every = <frequency>, Interval = <int> }
new ScheduleRuleTime { Rule = <rule>, At = <TimeSpan> } // local time of day
new ScheduleRuleWeekday { Rule = <rule>, Day = <DayOfWeek> }
new Osyrin.Scheduling.ScheduleRuleMonthDay { Rule = <rule>, Day = <int> } // negative counts from the end: -1 is the last day
new ScheduleRuleMonth { Rule = <rule>, Month = <Month> }
new Osyrin.Scheduling.ScheduleExclusion { Schedule = <schedule>, From = <DateTime>, To = <DateTime>, Reason = <string> }Description#
What an occurrence does#
When a schedule comes due the platform creates one row of the entity its Template names and re-arms the schedule for its next
occurrence. It does not start a workflow, call a function, or run any code you wrote. Everything after the row is the
platform's ordinary behaviour: a workflow that tracks that entity and autostarts will start, exactly as it would for a
row created by a person.
That is why a scheduled workflow must be autostart-able on its tracked entity. It is a real constraint and it is the honest one — the schedule produces rows and the workflow reacts to rows.
Making a schedule about a particular row — Template#
Template is an object initializer, so it sets members on the row each occurrence creates. That is the difference
between a schedule that is only a cadence and one that is about something — "chase this order every morning"
rather than "run every morning":
Template = new ChaseTask { Order = order, Attempt = 1 },Everything in that line is an identifier, checked when you compile: the entity, and every member of it. A mistyped member is an error at the line that made it, not a surprise on the night the job first runs.
Two things worth knowing about the values:
- A reference is stored as the row it points at, so
Order = orderon the occurrence points at that same order. - They are captured when the schedule is created, not re-evaluated per occurrence. A schedule is a row, so
Attempt = 1means every occurrence gets1— not an incrementing counter. Anything that has to differ per occurrence belongs in the workflow that starts on the row.
⚠ A member the target STOPS declaring is skipped, not fatal. A schedule row outlives the deploys that reshape its target, and failing the sweep over one stale field would stop a nightly job for everyone. It is reported in the occurrence's note (see audit read access (app.Audit)) rather than silently ignored.
The cadence is a set of rules, and each rule is a cross product#
A schedule owns one or more rules. A rule has a frequency (Once, Minute, Hour, Day, Week, Month,
Year) and an interval (1 = every, 2 = every other). Its by-rule sets — weekdays, days of the month, months,
times of day — combine as a cross product: two days × two times is four occurrences, not two.
A cross product cannot express "Monday at 09:00 and Friday at 17:00" — different times on different days. That is two rules, and it is why a schedule owns a list of them rather than one.
Nothing about when it fires comes from when you created it#
Each frequency requires the sets that pin it:
Every | required | why |
|---|---|---|
Minute, Hour | — | a pure interval, measured from EffectiveFrom |
Day | at least one time | otherwise its hour would come from nowhere |
Week | weekdays + a time | otherwise its DAY would be whenever you happened to create the row |
Month | days-of-month + a time | |
Year | months + days-of-month + a time |
EffectiveFrom is required for the same reason, and it does more than say when the schedule starts: it is the
phase origin. Every = Week, Interval = 2 with Day = Monday says which weekday and nothing about which week —
the origin is what decides that, and the same is true of which quarter a 3-monthly rule lands in.
The result is that two apps with the same schedule, created on different days, fire at the same instants.
Missed occurrences COALESCE#
If nothing runs for three days, the schedule produces one row when it comes back, not seventy-two. The answer is always the next occurrence after now; the ones that were missed are gone. This matches how repeating reminders behave (Remind (milestone reminders)).
They are gone, but they are not unrecorded: the occurrence that catches up says how many it absorbed, in the log described under History.
What if the last run has not finished?#
Skip (the default) passes over an occurrence while the previous one's work is still running, and the skipped
occurrence is consumed, not deferred — a job that overruns must not build a backlog it can never work off. Allow
says the work is safe to run concurrently with itself.
A skipped occurrence produces nothing, so it leaves no run and no row of your target entity. It leaves an occurrence record — again, see History — which is the only place a skip is visible.
Does a daily 02:00 stay 02:00 across DST?#
Every local time is interpreted in the schedule's Zone. A daily 02:00 is 02:00 locally across daylight-saving
boundaries, so consecutive occurrences are 23 or 25 hours apart in UTC twice a year rather than always 24.
⚠ The canonical maintenance hour is the one that disappears: in much of Europe and North America 02:00 does not exist on the spring-forward date. The occurrence lands just after the gap, at 03:00 local — the same rule the platform applies to every civil time it resolves.
Exclusions, and how they differ from an end date#
A ScheduleExclusion is a date range the schedule does not fire in — a holiday, a change freeze. It suppresses
occurrences and does not end the schedule: the cadence resumes after it. EffectiveUntil is the opposite; it ends
the schedule for good, and a lapsed schedule becomes Retired rather than being deleted, so when did this last run
survives the stopping.
⚠ EffectiveUntil is an instant, not a day. Setting it to a bare date means midnight that morning, which cuts off
that day's own occurrences — 2026-12-31 does not mean "through the 31st". Write 2026-12-31 23:59 if that is what
you meant.
Naming the types#
Every type may be written by its full namespace, as in C# — new Osyrin.Scheduling.Schedule { … },
Osyrin.Scheduling.ScheduleFrequency.Day — or by its bare name once the app declares it. The examples below use the
qualified form because it reads unambiguously next to an app's own types; both are legal everywhere.
Declaring who may reach a schedule#
The platform ships the SHAPE of a schedule and the app owns the ROWS, so — like the business-hours calendar (ServiceHours (SLA-accrual windows)) — nothing is readable or writable until the app says who may reach it:
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }One block covers the whole schedule. The rules, their times, weekdays and month-days, and the exclusions are all part of a schedule — they cannot exist without one — so they take that rule too (rows that are part of another row). A schedule decomposing into six tables is a fact about the shape, not six decisions for you to make.
A child may still state its own rule, and it then stops deriving — one source for one answer, never a derived rule sitting underneath an explicit one. Reach for that only when a child genuinely differs from its parent, which is rarer than it sounds: a rule readable by someone who cannot read its schedule is usually a mistake rather than a policy.
Pausing#
Set Status = Paused to stop producing without losing the schedule or its phase; set it back to Active and the
platform arms the next occurrence from the rules. Neither needs any other change.
Testing one — make 02:00 happen#
A schedule is the one trigger with no person behind it, so a test cannot simply do the thing that starts it. Move the clock past the occurrence and settle:
TestClock.Advance(TimeSpan.FromDays(1)); // past the next occurrence
Background.Settle(); // the sweep runs, the row is produced, its workflow starts
var run = DigestRun.Single(r => r.State == DigestStatus.Sent);TestClock.Advance moves the clock and nothing else; the settle is what looks. One settle
covers the whole chain — the occurrence creates the target row, and anything that starts from that row (an
autostarting workflow, its entry body) runs in the same call.
⚠ Workflow.Settle(x) is the wrong verb here and cannot be made to work. It settles a named entity's run, and at
the moment a schedule fires there is nothing to name — the row is the occurrence's output. Background.Settle()
takes no argument for exactly that reason.
⚠ One settle produces ONE occurrence, however far the clock jumped. That is [[#coalesce|coalescing]], not a limitation of the test surface: a week's advance on a nightly schedule yields a single row, which is what production does after an outage. A test that expected seven would be encoding behaviour no deployment has.
What ran, what was skipped, and why#
Each occurrence's run carries the ordinary workflow trail (For(entity).Audit). But a run only exists for an
occurrence that produced something, and the questions people actually ask a schedule are about the ones that did
not — so the platform also keeps an occurrence log: one row for every occurrence it considered, on the
ScheduleOccurrenceRecord audit surface (audit read access (app.Audit)).
Every consideration is recorded, including the ordinary ones, and that is the point rather than an excess. It is what gives an absent row a meaning: no record for last night means the platform never looked, which is a different fault, with a different fix, from a night that was skipped. A log holding only the exceptions leaves those two indistinguishable — which is the state this exists to end.
Each row carries:
| field | what it says |
|---|---|
DueAt | the instant the occurrence was owed |
At | the instant the platform looked — on a catch-up these differ by the whole outage |
Outcome | Produced · Skipped · Retired · UnknownTarget |
ProducedRow | the row this occurrence created, on Produced |
CoalescedCount | how many further occurrences this one absorbed; 0 on an ordinary night |
Note | the sentence, when the outcome alone does not say it |
UnknownTarget means the schedule's target entity is no longer in the model — a deploy dropped the entity out from
under it. The schedule stays Active and keeps re-arming, so a later deploy that restores the entity resumes
production on its own; until then every occurrence is recorded as one of these.
It is an ordinary audit surface, so it is read-only to your app, closed until you open it, and bounded by the
window you declare. A schedule produces work whether or not anyone is watching, so unlike the other trails this one
is worth a Retention:
[Principal] entity Operator { [Required] string Email; bool IsOnCall; }
entity DigestRun { }
app.Audit = new AuditConfig {
ScheduleOccurrenceRecord = new AuditSurface {
Read = user => user.IsOnCall,
Retention = TimeSpan.FromDays(90),
}
};
// "What happened last night" — answered from data rather than from a log file. The surface names a TYPE as well as
// a set, so the rows come back and the caller sees WHICH occurrences and why, not just how many.
ScheduleOccurrenceRecord[] SkippedSinceYesterday() {
return ScheduleOccurrenceRecord
.Where(o => o.At > DateTime.UtcNow.AddDays(-1)
&& o.Outcome == Osyrin.Scheduling.ScheduleOccurrenceOutcome.Skipped)
.ToList();
}Read-only survives being held: a value of this type can be returned, passed and read, and a write to one is refused wherever it is reached from. The rule is about the trail, not about the spelling that got you there.
Examples#
Nightly at 02:00 Stockholm time. The workflow autostarts on the row each occurrence creates:
// `IsAuthenticated` asks whether there is a signed-in principal, so the app has to have one.
[Principal] entity Operator { [Required] string Email; }
// Baseline SHAPE, app-owned ROWS: the platform ships what a schedule IS and the app owns the rows, so under the
// security model the app must say who may reach them. Same requirement `ServiceHours` carries, and for the same
// reason — nothing is readable or writable until the app says so.
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
enum ReconcileStatus { Pending, Done }
entity ReconcileRun {
ReconcileStatus State;
DateTime? FinishedAt;
}
workflow Reconcile {
Tracks = ReconcileRun.State;
Autostart = true;
Initial = Pending;
state Pending {
enter { this.Item.FinishedAt = DateTime.UtcNow; goto Done; }
}
terminal success Done { }
}
void SeedNightlyReconcile() {
if (Osyrin.Scheduling.Schedule.Any()) { return; } // idempotent — a second call mints no second schedule
var nightly = new Osyrin.Scheduling.Schedule {
Name = "Nightly reconciliation",
Template = new ReconcileRun { },
Zone = "Europe/Stockholm",
EffectiveFrom = DateTime.UtcNow
};
var rule = new Osyrin.Scheduling.ScheduleRule {
Schedule = nightly,
Every = Osyrin.Scheduling.ScheduleFrequency.Day,
Interval = 1
};
new Osyrin.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(2) };
}Weekdays at 09:00 and 17:00 — six occurrences a week from one rule, because the sets cross-multiply:
// `IsAuthenticated` asks whether there is a signed-in principal, so the app has to have one.
[Principal] entity Operator { [Required] string Email; }
// Baseline SHAPE, app-owned ROWS: the platform ships what a schedule IS and the app owns the rows, so under the
// security model the app must say who may reach them. Same requirement `ServiceHours` carries, and for the same
// reason — nothing is readable or writable until the app says so.
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
entity ShiftReport { }
void SeedShiftReports() {
var s = new Osyrin.Scheduling.Schedule {
Name = "Shift report",
Template = new ShiftReport { },
Zone = "UTC",
EffectiveFrom = DateTime.UtcNow
};
var rule = new Osyrin.Scheduling.ScheduleRule {
Schedule = s,
Every = Osyrin.Scheduling.ScheduleFrequency.Week,
Interval = 1
};
foreach (var d in [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday]) {
new Osyrin.Scheduling.ScheduleRuleWeekday { Rule = rule, Day = d };
}
new Osyrin.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(9) };
new Osyrin.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(17) };
}Month-end billing, skipping a December freeze. -1 is the last day of whatever month it lands in — the only way to
say "month end" across months of different lengths:
// `IsAuthenticated` asks whether there is a signed-in principal, so the app has to have one.
[Principal] entity Operator { [Required] string Email; }
// Baseline SHAPE, app-owned ROWS: the platform ships what a schedule IS and the app owns the rows, so under the
// security model the app must say who may reach them. Same requirement `ServiceHours` carries, and for the same
// reason — nothing is readable or writable until the app says so.
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
entity Account { [Required, MaxLength(60)] string Name; }
entity BillingRun { Account Account; [MaxLength(20)] string Kind; }
void SeedBilling() {
var acct = new Account { Name = "Acme" };
var s = new Osyrin.Scheduling.Schedule {
Name = "Month-end billing",
// The template carries CONTEXT: every occurrence mints a BillingRun pointing at this account.
Template = new BillingRun { Account = acct, Kind = "month-end" },
Zone = "UTC",
EffectiveFrom = DateTime.UtcNow
};
var rule = new Osyrin.Scheduling.ScheduleRule {
Schedule = s,
Every = Osyrin.Scheduling.ScheduleFrequency.Month,
Interval = 1
};
new Osyrin.Scheduling.ScheduleRuleMonthDay { Rule = rule, Day = -1 };
new Osyrin.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(23) };
new Osyrin.Scheduling.ScheduleExclusion {
Schedule = s,
From = DateTime.Parse("2026-12-20"),
To = DateTime.Parse("2027-01-02"),
Reason = "Change freeze"
};
}See also#
- Tracks and Initial (the field a workflow drives) — the entity a workflow tracks, which is what a schedule's
Templatenames - Remind (milestone reminders) — reminders inside a run, and the same coalescing rule for missed cadences
- ServiceHours (SLA-accrual windows) — the business-hours calendar an SLA clock accrues against (a different job from a schedule)
- For(entity).Audit — the trail each occurrence's run leaves
- audit read access (app.Audit) —
app.Audit, which gates and bounds theScheduleOccurrenceRecordoccurrence log