Summary#
A Remind schedules a side-effect off a Assigned / Finished (milestones) — a nudge that runs while the slot is still waiting,
without transitioning the run. Its schedule is config, written in the header parentheses; its body is the block.
After (required) is the first fire, at enter + After. ThenEvery (optional) repeats the body every interval
after that. Reading the header tells you exactly when it fires — there is no hidden anchor. A reminder body may not
goto — reminders are config, not transitions.
Signature#
Remind Nudge(After = <TimeSpan>) { <body> } // one-shot, at enter + After
Remind Chase(After = <TimeSpan>, ThenEvery = <TimeSpan>) { <body> } // then repeats every intervalThenEvery requires an After (it repeats the first fire). The header is config only; the { } block is the body.
The old single-brace form (Remind { After = …; <statements> }) is not valid — config and body live in separate places.
The NAME is required, and it is the reminder's identity: Remind Nudge(After = …). A milestone may carry more
than one reminder, so an unnamed Remind(…) is refused rather than guessed at — the parser says so by name.
Description#
A reminder lives inside a milestone (Assigned { … } / Finished { … }) and shares its ambient slot, so its body
can read slot.Assignee / slot.Candidates and call your own app functions to act on them (e.g. a Notify(user) that
sends over a client, or a foreach (var u in slot.Candidates) Notify(u)). Each firing writes a Reminded event to
the timeline (readable via For(entity).Audit), which is the observable artifact a test asserts on.
The schedule reads as one timeline:
After = T— the first (and, alone, only) fire, atenter + T.After = Within / 2reads the enclosing milestone'sWithin(the bare nameWithinin a reminder schedule inlines the milestone'sWithinexpression) and nudges at the halfway mark.ThenEvery = T— repeats the body everyTafter the first fire (After,After + T,After + 2T, …). A poll from the start is justAfter = T, ThenEvery = T.
A repeating reminder stops when its milestone resolves — Assigned once the slot leaves Unassigned, Finished once
it is Satisfied, or when the milestone breaches (Unassigned/Unfinished fires). So the reminder nudges before the
deadline while the breach handler owns the deadline itself; they never overlap and a reminder never nags forever.
A missed cadence is COALESCED, not replayed#
If the clock passes several due firings before the engine next runs — a scheduler that was down, a long jump in a
test — the reminder fires once on the next advance, not once per interval it slept through. A ThenEvery = 2d
reminder that goes unattended for a month sends one message, not fifteen.
This is what you want in production: coming back from an outage should not deliver a month of backlog to a customer. It has one consequence worth knowing, and it is easy to read as a bug in the reminder:
A test has to tick the way a scheduler does. Advancing the clock 30 days and settling once proves that the reminder still fires, not that it repeats. To assert a cadence, advance and settle per interval.
// asserts that it REPEATS — three separate advances, three settles
TestClock.Advance(TimeSpan.FromDays(3)); Workflow.Settle(ticket); // After = 3d
TestClock.Advance(TimeSpan.FromDays(2)); Workflow.Settle(ticket); // ThenEvery = 2d
TestClock.Advance(TimeSpan.FromDays(2)); Workflow.Settle(ticket);On the run's whole deadline#
A milestone belongs to one slot in one state, and its clock starts over when the state is entered again. The run's
Deadline is different: it spans the whole run, accruing in the states named by Accrues and pausing in the rest. A
reminder written inside it is measured on that clock:
Deadline {
Within = this.Item.ResolveWithin;
Remind AtRisk(After = Within * 0.75) { Warn(this.Item); } // at three quarters of the time ACCRUED
}- It fires when the deadline has accrued
After— so a run that sat for a week in a state outsideAccruesis reminded at the same point in its promise as one that never waited. Withinis the deadline's own budget, as it is a milestone's.- When the deadline is retargeted (
Workflow.Retarget()), the reminder moves with it, keeping the time already accrued: "at three quarters" stays three quarters of the new promise. A slot milestone's reminders move the same way (Workflow.Retarget (re-base the SLA clocks)); one that has already fired is left alone. - There is no slot, so its body has no ambient
slot. It fires once (or everyThenEvery), never after the run ends.
Before a known instant — an expiry, a start time#
A reminder is scheduled from when its clock started, so "a week before the lease ends" is a run whose deadline IS
that instant, with the reminder a week short of it. Within may read the row the run tracks: measured from the moment
the run began, the instant is this.Item.EndsAt - this.Item.SignedAt, and the reminder fires at Within - TimeSpan.FromDays(7). See the last example below; TestClock.Advance and TestClock.Set drives it in a test.
A reminder that moves its own run#
A poll is a reminder whose body asks something and, on the right answer, raises the run's own event:
Remind Track(After = …, ThenEvery = …) { if (Delivered(this.Item)) { Flow.RaiseArrive(this.Item); } }. The raise is
delivered as soon as the reminder's step ends, in the same Workflow.Settle — see [[workflow-raise#own-run]]. The
reminder itself may not goto; raising is how it hands the decision to the state's own arm.
Examples#
using Osysharp.Workflow;
enum Decision { Approve, Reject }
enum OrderState { Review, Done }
[Principal]
entity Person {
[Required, MaxLength(200)] string Email;
security { allow read, create when IsAuthenticated; }
}
entity Order {
[Required, MaxLength(60)] string Reference;
[Required] Person Requester;
OrderState Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated; }
}
workflow ReminderFlow {
Tracks = Order.Status;
Autostart = true;
Initial = Review;
event Approve(Decision decision);
state Review {
subscribe Approve(Decision decision) as Legal {
Candidates = u => u.Email != "";
Finished {
Within = TimeSpan.FromHours(8);
// The header is config; the { } is the body. A reminder is NAMED — see the signature above.
Remind Nudge(After = TimeSpan.FromHours(2)) { }
Unfinished { }
}
}
on Approve(Decision decision) { goto Done; }
}
terminal success Done { }
}Nudge the pool halfway through the window, then chase hourly until someone picks it up or the breach fires:
A resolution promise that warns at three quarters of its accrued time, before it breaches:
using Osysharp.Workflow;
enum CaseState { Open, Waiting, Closed }
entity Case {
[Required, MaxLength(200)] string Title;
TimeSpan ResolveWithin = TimeSpan.FromHours(8);
bool AtRisk = false;
CaseState Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow CaseFlow {
Tracks = Case.Status;
Autostart = true;
Initial = Open;
Accrues = [Open]; // waiting on the customer stops the clock — and the reminder with it
Deadline {
Within = this.Item.ResolveWithin;
Remind AtRisk(After = Within * 0.75) { this.Item.AtRisk = true; }
}
event Wait();
event Resume();
event Close();
state Open { on Wait { goto Waiting; } on Close { goto Closed; } }
state Waiting { on Resume { goto Open; } }
terminal success Closed { }
}A week before a known instant — the deadline falls when the lease ends, and the reminder a week short of it:
using Osysharp.Workflow;
enum LeaseState { Running, Ended }
entity Lease {
[Required, MaxLength(80)] string Tenant;
DateTime SignedAt = DateTime.UtcNow;
DateTime EndsAt;
bool RenewalOffered = false;
LeaseState Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow LeaseFlow {
Tracks = Lease.Status;
Autostart = true;
Initial = Running;
// Measured from when the run began (the signing), the deadline falls when the lease ends.
Deadline {
Within = this.Item.EndsAt - this.Item.SignedAt;
Remind WeekBefore(After = Within - TimeSpan.FromDays(7)) { this.Item.RenewalOffered = true; }
}
event End();
state Running { on End { goto Ended; } }
terminal success Ended { }
}Assigned {
Within = TimeSpan.FromHours(4);
Remind Nudge(After = Within / 2, ThenEvery = TimeSpan.FromHours(1)) { // 2h, then 3h, 4h, …
foreach (var u in slot.Candidates) { Notify(u); }
}
Unassigned { … } // the deadline is owned here, separately
}Drive it in a test and assert on the timeline, not the delivery:
TestClock.Advance(TimeSpan.FromHours(2)); // Remind Nudge(After = Within / 2, …) first fire
Workflow.Settle(po);
var audit = PoApproval.For(po).Audit;
Assert.Equal(1, audit.Count(a => a.Kind == AuditKind.Reminded && a.Slot == "Legal"));
Assert.True(PoApproval.For(po).Legal.IsUnassigned); // …and we are STILL WAITINGSee also#
- Assigned / Finished (milestones) — the SLA a reminder is scheduled off (and the source of
Within) - For(entity).Audit — the
Remindedtimeline event each firing writes