# Schedule (recurring work)

> A schedule produces work on a cadence. Each occurrence creates a row of the entity its Template names — carrying whatever context the template sets — and a workflow that tracks that entity then starts on its own. Schedules are DATA, not syntax, so a cadence changes without a deploy.

<!-- id: scheduling-schedule · area: scheduling · stability: preview · html: https://osysharp.com/reference/scheduling/schedule/ -->

## Summary        {#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)](https://osysharp.com/reference/workflow/tracks/) 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      {#signature}
```osy syntax
new Osysharp.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 Osysharp.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 Osysharp.Scheduling.ScheduleRuleMonthDay { Rule = <rule>, Day   = <int> }        // negative counts from the end: -1 is the last day
new ScheduleRuleMonth    { Rule = <rule>, Month = <Month> }
new Osysharp.Scheduling.ScheduleExclusion { Schedule = <schedule>, From = <DateTime>, To = <DateTime>, Reason = <string> }
```

## Description    {#description}

### What an occurrence does   {#occurrence}
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-members}
`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":

```osy syntax
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 = order` on 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 = 1` means every occurrence gets `1` — 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)](https://osysharp.com/reference/config/audit/)) rather than silently ignored.

### The cadence is a set of rules, and each rule is a cross product   {#rules}
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   {#required-sets}
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     {#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)](https://osysharp.com/reference/workflow/remind/)).

They are gone, but they are **not unrecorded**: the occurrence that catches up says how many it absorbed, in the log
described under [History](#history).

### What if the last run has not finished?   {#overlap}
`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](#history) — which is the only place a skip is visible.

### Does a daily 02:00 stay 02:00 across DST?   {#zones}
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   {#exclusions}
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   {#naming}
Every type may be written by its **full namespace**, as in C# — `new Osysharp.Scheduling.Schedule { … }`,
`Osysharp.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   {#security}
The platform ships the SHAPE of a schedule and the app owns the ROWS, so — like the business-hours calendar
([ServiceHours (SLA-accrual windows)](https://osysharp.com/reference/workflow/service-hours/)) — nothing is readable or writable until the app says who may reach it:

```osy syntax
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](https://osysharp.com/reference/security/part-of-derived-access/)).
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   {#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   {#testing}
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:

```osy syntax
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`](https://osysharp.com/reference/testing/clock-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     {#history}
Each occurrence's run carries the ordinary workflow trail ([For(entity).Audit](https://osysharp.com/reference/workflow/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)](https://osysharp.com/reference/config/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`:

```osy title="occurrence-log" test app=scheduling-occurrence-log
[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 == Osysharp.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       {#examples}

Nightly at 02:00 Stockholm time. The workflow autostarts on the row each occurrence creates:

```osy title="nightly" test app=scheduling-nightly
// `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 (Osysharp.Scheduling.Schedule.Any()) { return; }        // idempotent — a second call mints no second schedule

  var nightly = new Osysharp.Scheduling.Schedule {
    Name          = "Nightly reconciliation",
    Template      = new ReconcileRun { },
    Zone          = "Europe/Stockholm",
    EffectiveFrom = DateTime.UtcNow
  };
  var rule = new Osysharp.Scheduling.ScheduleRule {
    Schedule = nightly,
    Every    = Osysharp.Scheduling.ScheduleFrequency.Day,
    Interval = 1
  };
  new Osysharp.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:

```osy title="twice-daily-on-weekdays" test app=scheduling-weekdays
// `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 Osysharp.Scheduling.Schedule {
    Name          = "Shift report",
    Template      = new ShiftReport { },
    Zone          = "UTC",
    EffectiveFrom = DateTime.UtcNow
  };
  var rule = new Osysharp.Scheduling.ScheduleRule {
    Schedule = s,
    Every    = Osysharp.Scheduling.ScheduleFrequency.Week,
    Interval = 1
  };
  foreach (var d in [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday]) {
    new Osysharp.Scheduling.ScheduleRuleWeekday { Rule = rule, Day = d };
  }
  new Osysharp.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(9) };
  new Osysharp.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:

```osy title="month-end-with-a-freeze" test app=scheduling-monthend
// `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 Osysharp.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 Osysharp.Scheduling.ScheduleRule {
    Schedule = s,
    Every    = Osysharp.Scheduling.ScheduleFrequency.Month,
    Interval = 1
  };
  new Osysharp.Scheduling.ScheduleRuleMonthDay { Rule = rule, Day = -1 };
  new Osysharp.Scheduling.ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(23) };

  new Osysharp.Scheduling.ScheduleExclusion {
    Schedule = s,
    From     = DateTime.Parse("2026-12-20"),
    To       = DateTime.Parse("2027-01-02"),
    Reason   = "Change freeze"
  };
}
```

## See also       {#see-also}
- [Tracks and Initial (the field a workflow drives)](https://osysharp.com/reference/workflow/tracks/) — the entity a workflow tracks, which is what a schedule's `Template` names
- [Remind (milestone reminders)](https://osysharp.com/reference/workflow/remind/) — reminders inside a run, and the same coalescing rule for missed cadences
- [ServiceHours (SLA-accrual windows)](https://osysharp.com/reference/workflow/service-hours/) — the business-hours calendar an SLA clock accrues against (a different job from a schedule)
- [For(entity).Audit](https://osysharp.com/reference/workflow/audit/) — the trail each occurrence's run leaves
- [audit read access (app.Audit)](https://osysharp.com/reference/config/audit/) — `app.Audit`, which gates and bounds the `ScheduleOccurrenceRecord` occurrence log
