Skip to content
Registry StackDocsDevelopment (unreleased)

Author a Scheduling policy

For the operator

View as Markdown

If you are writing the policy a Registry Scheduling deployment will serve, you edit one project directory and prove it offline. You continue in ./scheduling, the project from Prove your first booking policy, where the fixture already closes station-2.

An offering is bookable only inside an opening: the weekly hours one location publishes. check listed two under effective.openings. This is the one that serves registry-update-30:

scheduling/scheduling.yaml
- id: bangkok-counter-hours
location: bangkok-counter
holidaySet: office-holidays
weekdays: [mon, tue, wed, thu, fri]
startTime: "09:00"
endTime: "12:30"
effectiveFrom: "2026-10-01"
effectiveUntil: "2026-12-31"

Times are wall-clock times at the location, read in the time zone its location record names, so the counter opens at 09:00 Bangkok time on weekdays. The opening ends on effectiveUntil; from 2027 this offering publishes no starts until you extend that date and deploy the change.

Every opening names a holiday set, and a date in that set closes the opening for the whole day. The starter’s set closes nothing yet. Close Tuesday 13 October 2026:

scheduling/scheduling.yaml
- id: office-holidays
revision: 1
dates:
- "2026-10-13"
because: Public holidays observed by the registry offices.

Prove it with a case at the end of the counter fixture. A booking at 09:00 that day must be refused:

scheduling/fixtures/counter-stations.yaml
- name: the-holiday-is-not-served
request:
offering: registry-update-30
start: 2026-10-13T02:00:00Z
party:
recipients: 1
attendees: 1
duplicateKey: subject:four
policyRevision: 1
expect:
type: refused
code: schedule.unpublished
Terminal window
schedulingctl test ./scheduling --format json
{
"detail": "refused schedule.unpublished (detailed: schedule.unpublished)",
"name": "the-holiday-is-not-served",
"status": "pass"
}

schedule.unpublished is the answer for any start the policy does not publish, whether the day is a holiday, a weekend, or outside the opening hours.

A closure that affects one location for part of a day is not policy. It is an exception record in records.yaml, which the operator applies to a running deployment without a new package:

scheduling/records.yaml
exceptions:
- id: hall-prep
location: new-york-hall
kind: closure
date: "2026-11-01"
startTime: "00:15"
endTime: "00:45"

A caller first takes a hold on a start, then confirms it as an appointment. The hold policy applies to every offering:

scheduling/scheduling.yaml
holdPolicy:
ttlMinutes: 5
maximumPerCaller: 3
because: Holds are short because counter capacity is scarce.

An unconfirmed hold returns its station to the pool after five minutes, and one caller holds at most three at a time. The offering adds its own rules:

scheduling/scheduling.yaml
cancellationCutoffMinutes: 240
reminders:
- offsetMinutes: 1440
because: One reminder the day before a counter update.
- offsetMinutes: 120
because: A second reminder two hours before, when stations are scarce.
duplicateActiveKey: subject

A caller may not cancel within four hours of the start. Each confirmed appointment records two reminder intents, due one day and two hours before it; the runtime delivers them to the reminder destination the operator configures, or keeps them readable in its outbox when there is none. duplicateActiveKey: subject is the rule the fixture’s second case proved: one active booking per subject.

Every entry carries because, a sentence for the next person who reads the policy. It is part of the policy, so a changed reason changes the digest too.

Tell another system when an appointment is confirmed

Section titled “Tell another system when an appointment is confirmed”

An observer hook sends a signed event after an appointment is confirmed, rescheduled, or cancelled. The policy names the trigger and the fields it sends; it holds no address and no secret. Add one at the end of scheduling.yaml:

scheduling/scheduling.yaml
hooks:
- id: appointment-confirmed
phase: after
trigger: appointment.confirmed
projection:
- appointmentId
- start
handler:
type: url
destinationId: case-system
Terminal window
schedulingctl check ./scheduling
error[scheduling.runtime.unbound-hook-destination] ./scheduling/runtime.example.yaml:<position> /destinations/hooks
destinations.hooks must bind every destination scheduling.yaml hooks[].handler.destinationId names

check reads runtime.example.yaml as the runtime reads its own file, and refuses a hook with nowhere to deliver. The address and signing key belong to the deployment. Bind them there:

scheduling/runtime.example.yaml
destinations: {}
destinations:
hooks:
case-system:
url: https://cases.example.test/scheduling-events
hmacSha256KeyRef: secret:file/case-system-hmac
Terminal window
schedulingctl check ./scheduling
Authoring check passed.

The projection is closed per trigger. A confirmed or rescheduled appointment may send appointmentId, revision, offering, start, end, state, and policyRevision; a cancellation sends only appointmentId, revision, and state. check refuses any other field with scheduling.project.unsupported-hook-projection, so a hook never sends the station, the caller, or the cancellation reason. A cancellation intent sent to the reminder destination does carry the reason. Delivery happens after the appointment commits, at least once, signed with HMAC-SHA256, and the receiver’s answer never changes a booking.

The runtime serves a package, not the project directory. Ask what the package would hold:

Terminal window
schedulingctl package ./scheduling --dry-run --format json
{
"dryRun": true,
"runtimeConfigurationIncluded": false,
"secretsIncluded": false
}

The report lists one file, scheduling.yaml, and a packageDigest the runtime verifies at startup. records.yaml and the runtime file stay out: the operator applies records to the database and keeps the runtime file with the deployment.

The standalone-arrival-window template sells arrival windows instead of exact times: a caller books a place in a two-hour block, and the block’s capacity is counted in units. Create it beside your project and explain it:

Terminal window
schedulingctl init ./arrival --template standalone-arrival-window
schedulingctl explain ./arrival --format json
{
"arrival": {
"horizonDays": 45,
"leadTimeMinutes": 60,
"window": "household-morning-window"
},
"id": "household-morning",
"mode": "arrival-window"
}

The window itself is a record in ./arrival/records.yaml, with its start, end, and units. A subquotas entry caps how many of those units one channel, public or assisted, may take. A subquota is a ceiling, not a reservation: the other channel may still use units the first leaves free.

The Registry Scheduling configuration reference lists every key of scheduling.yaml, records.yaml, the fixtures, and the runtime file.

Deploy and operate Registry Scheduling packages this project, activates it on PostgreSQL, applies its records, and starts the runtime.