A schedule is work triggered by time rather than by anybody acting. Declaring it in the model rather than as a cron entry in a deployment manifest is the whole point: a cron entry is the same job with nothing in the model knowing it exists, no observability span carrying its name, and the fact that your application deletes rows on a timer discoverable only by reading infrastructure.
schedule SessionSweep {
every day at 03:20 tz "UTC"
scope global
run_as_sudo
use_case PurgeExpiredSessions
input { as_of: "fire.at", retain_for: "'720h'", sweep_ref: "fire.id" }
systems [AuthorizationSystem]
doc "Nightly delete of sessions whose expiry is more than 30 days past."
retry {
max_attempts 3
backoff exponential
on_failure dead_letter
}
}When, what, and how are three separate things
A schedule declares when. It does not do the work: it dispatches a use_case, a command, a gateway operation or a bespoke, and that target declares what. The implementation underneath declares how.
Keeping them apart is what lets the same work be triggered two ways — nightly on a timer, and on demand by an operator — without a second copy of it.
Cadence
every day at 03:20 tz "UTC" // a daily wall-clock time, in a named zonecron, every, at, day_of_week, day_of_month and calendar_unit are all available; tz names the zone the wall-clock time is read in, which matters the moment a business operates somewhere that observes daylight saving.
Choose the minute deliberately.
03:20rather than03:00is not fussiness: the top of the hour is where everything in every estate fires. Landing just after an hour boundary also means a run that overshoots is still in the quiet part of the night.
Scope, and the fan-out
scope global // one firing for the whole deployment
scope per_tenant // one firing per tenantper_tenant is the right answer for work that belongs to each tenant's data — and it is also how ten thousand tenants come to aim the same job at the same instant. stagger spreads them out, and it is worth reaching for before the tenant count makes the problem obvious.
Pass the fire time in, do not compute it inside
This is the detail that separates a schedule that retries cleanly from one that merely runs again:
input { as_of: "fire.at", retain_for: "'720h'", sweep_ref: "fire.id" }fire.at is the schedule's own firing time, handed to the work as an input. A retry therefore re-runs the same window rather than a slightly later one — which is what makes a retried sweep idempotent instead of repeated.
fire.id carries the firing's identity into logs and errors, so two runs are legibly two runs rather than one confusing one.
Compute now() inside the work instead and every retry silently operates on a different window. Nothing fails; the results just stop being reproducible.
Elevation, stated twice
run_as_sudoA scheduled firing has no caller. There is no signed-in person behind 03:20, so work that reads or writes across tenant boundaries needs explicit elevation.
Declare it on the schedule and on the use case it dispatches. That looks redundant and is not: either one alone is a rule enforced somewhere a reader is not looking, and elevation is exactly the kind of thing a reviewer should not have to follow a reference to discover.
Retries and misfires
retry { max_attempts 3 backoff exponential on_failure dead_letter }Two more knobs worth knowing:
on_overlap— what happens when a firing is still running as the next one comes due. A nightly sweep that has started taking longer than a day is a problem you want reported, not one you want quietly running twice.on_misfire— what happens to a firing the system was down for. Run it late, or skip it? For a retention sweep, skipping is fine. For anything that bills somebody, it is not.
Inspecting them
vishwakarma schedules list # every declared schedule: cadence, scope, policies, kill switch
vishwakarma schedules next # the next five instants of each
vishwakarma schedules fire # claim one instant by handlist and next read your model and touch nothing. fire is the only write: it claims an instant through the same transaction a scheduled firing uses, so a manual fire and a scheduled one contend for one row rather than both running — which is what makes "run it now to check" safe on a live system.
Running them
Schedules are executed by their own process, separate from the API server. If it is not running, nothing fires — and nothing reports that nothing fired. Operational readiness in the assurance report is the lens that catches that class of gap.