Released docs. You are viewing the documentation published with v0.46.0. Development docs are available at Latest.
Registry Scheduling serves one HTTP surface for the published catalogue, availability, holds, and appointments. Every call presents a bearer access token, and one of three authority profiles decides what that token must carry: a read scope for catalogue and owned-record reads, a separate explain scope for the diagnostic availability read, or a complete task grant for every commitment. The contract ships in releases as an experimental surface: it stays outside the compatibility promise and may change in any minor release.
Open the Registry Scheduling API operations for every route, parameter, schema, and reachable problem code. A running Scheduling process does not serve the document itself.
Contract boundary
Section titled “Contract boundary”Every request presents Authorization: Bearer <token> holding an RFC 9068 access token, typ: at+jwt, whose claims name the caller’s actor kind in registry_actor_kind as human, agent, or
service. Liveness and readiness are the only routes that take no token.
The four caller-driven commitments carry an Idempotency-Key the caller chooses, bounded to 128
bytes: creating a hold, creating an appointment, rescheduling one, and cancelling one. A retry under
the same key with the same request returns the stored first answer, and the same key with a different
request is refused with idempotency.key-reused. Releasing a hold carries no caller key: the hold’s
own identifier is the idempotency key, so a retried release returns the first answer.
A 5xx answer may follow a commit, and so may a timeout or a broken exchange after the request was
sent. The safe recovery is the same request with the same key: the runtime replays the stored answer
or settles the command, while a new key can commit it twice. The Rust client in
registry-scheduling-client does this for the four keyed commitments: its error’s
is_outcome_unknown() is true in those cases and false for a typed 4xx refusal, and it resends the
identical request at most twice by default, waiting 250 ms and then 500 ms, or the runtime’s
Retry-After when that is longer. A Retry-After over 5 seconds ends the retries and returns
the answer. with_maximum_mutation_retries on its
configuration sets the count from 0, which sends once, to 2. It never resends a read or a release.
Observed revisions travel in request bodies, not in an If-Match header: an admission names the
policyRevision it resolved the offering under, and a reschedule or cancel names the appointment
revision it acted on. A stale revision is refused with policy.changed or revision.mismatch, and
the caller reloads the catalogue and tries again. An offering the deployed policy does not publish
is a separate answer, request.not-found: it does not exist, so no reload produces it, and the
offering listing every caller holding the reads scope can read is where the published ones are
named.
A refusal answers application/problem+json with type, title, status, detail, code, and
traceId. The type is the code with each dot replaced by a slash under
https://id.registrystack.org/problems/registry-scheduling/, and detail is the fixed sentence that
belongs to the code. No field repeats a submitted value. Every response, refusals included, carries
Cache-Control: no-store and propagates or mints a traceparent value.
Availability pages list a slot only while it holds a free unit: a slot whose members are all
committed, held, or closed leaves the page rather than appearing with zero free. A caller watching a
slot disappear therefore reads the same fact as one reading free: 0.
Authority profiles
Section titled “Authority profiles”The three profiles are disjoint. A token carrying a product scope does not thereby gain a commitment path, and a task grant does not thereby gain a catalogue read.
| Profile | Routes | The token must carry |
|---|---|---|
| Catalogue read | /v1/scheduling, /v1/services, /v1/offerings, /v1/resources, /v1/locations, /v1/availability, owned appointments listed by external reference, one appointment read, its history | The configured read scope, scheduling:read by default, in the configured scope claim, scope by default |
| Explain | /v1/availability/explain | The separately configured explain scope, scheduling:explain by default, which is never the read scope |
| Commitment | Hold create and release, appointment create, reschedule, cancel | A complete task grant; no product scope |
The explain path is separately authorized because it can name the member-level cause
resource.unavailable. The public availability path answers the same state as capacity.exhausted
and names no member.
POST /v1/appointments/receipt uses ordinary read authority, the original Idempotency-Key,
and the original CreateAppointmentRequest body to observe a retained successful creation
receipt. Its verified issuer and subject must exactly match the original caller. It returns
200 with the original appointment projection and never creates or replays a capacity command.
A confirmation from a hold is read back with the same {"hold": "<id>", "admission": null}
body, so both the hold and the key that confirmed it must match. Observation can therefore
continue after the original task grant ends, using a current token with read authority.
Missing, expired, changed, or refused receipts answer 409 receipt.unresolved. This refusal
leaves the original outcome unknown and is not evidence that no appointment was created.
Task grants
Section titled “Task grants”A commitment carries no product scope. Its authority is a task grant inside the access token: the six
registry_grant_* members that make a grant detectable, plus registry_purpose and
registry_approver, bound to the verified client and to this deployment’s audience before the caller
reaches the service. An incomplete grant set is refused as a credentials problem
(authentication.refused), never honored partially.
The grant’s bounds carry one permission per location, and a permission names an offering’s service, an offering’s location, and the actions it allows:
hold.createandhold.releaseappointment.create,appointment.reschedule, andappointment.cancel
A permission matches an offering only when its service and location both equal the offering’s own and the action is listed. The match is exact: a broader service or location never covers a narrower one, and the grant’s client must equal the client the deployment verified.
The grant’s bounds are matched at the service before the capacity transaction opens. Every runtime file
states taskGrantStatus, and a file that omits it is refused at startup with
scheduling.runtime.missing-task-grant-status. When taskGrantStatus lists task authorities, Scheduling
also obtains a fresh exact answer from the authority selected by the signed source issuer before every
commitment and idempotent receipt replay. The authority must say the grant is active and reproduce its
exact identity, bounds, signed subjects, and deadline. An inactive,
mismatched, unlisted, or incompletely bound grant answers operation.not-authorized; an authority or service
credential outage, including an unusable authority response, answers service.unavailable. Positive answers
are not cached, and the complete attempt is limited to ten seconds. No capacity lock is held during the outbound call.
This is a fresh observation rather than a distributed transaction: withdrawal after a positive answer can race
the in-flight local commit.
Inside the transaction, after lock waits and immediately before commit, Scheduling checks the earlier of
the verified access-token and grant deadlines. A credential that expires after the status answer cannot
take capacity through a delayed transaction. The earlier deadline bounds the status-to-commit race but does
not make revocation atomic across products. A runtime that writes
taskGrantStatus: unchecked-final-fifteen-minutes takes the bounded offline path instead: it asks no
authority, performs no revocation check, and accepts only grants with no more than 900 seconds remaining.
A list of authorities disables that path for every source, so a grant from an unlisted source is refused
even with less than 15 minutes left. Because the token does not name the original authorization mode, the
final 15 minutes of a deferred grant can use the offline path on a deployment that writes the keyword,
so a deployment that accepts deferred grants lists its task authorities.
A commitment writes a request audit entry, schema registry-scheduling-audit/v1, before the capacity
transaction opens, and a response entry with the same correlation after the transaction commits or
rolls back. The response carries authorization.allowed or authorization.refused as an audit reason,
never as a problem code. When the audit destination refuses the request entry, no transaction opens and
the call answers service.unavailable; when it refuses the response entry of a committed commitment,
the call answers service.unavailable with the commitment in place. A permission the service refuses
before any commitment is reached is one response entry with a correlation of its own.
Recover an unknown outcome
Section titled “Recover an unknown outcome”Send externalReferences naming the record you book for on every create an orchestrator makes. The
reference is how you find an outcome you never received without the idempotency key or the task grant
that produced it.
A lost answer or a 503 service.unavailable from a commitment does not prove that nothing was written:
when the audit destination refuses the response entry of a committed commitment, the call answers
service.unavailable with the commitment in place. Retry with the same Idempotency-Key and the same
request. The retry answers with the stored first outcome once the audit destination accepts its entry,
and it never commits a second time. A retry is itself a commitment, so it needs a live task grant that
covers the action. The Scheduling Rust client classifies an answer whose outcome is unknown and retries
it under the same key.
Once a task grant has expired, the token that carries it is refused with authentication.refused on
every /v1 route, its read scope included, and once the stored response has passed
retention.receiptRetentionDays, a retry answers 410 idempotency.expired. The sweep that erases the
stored response also clears the token issuer, subject, and key it was filed under, and keeps only a
SHA-256 digest of them and the command, so the key stays spent for that caller: a changed request
under it is still 409 idempotency.key-reused, and another caller’s identical key is unrelated. In
both cases, observe the outcome by reading:
- After a create, present a token for the same issuer and subject that carries the read scope and no
task grant, and call
GET /v1/appointmentswithexternalReferenceProduct,externalReferenceRecordType, andexternalReferenceIdentifier. A hold is not listed; one that is never confirmed expires and returns its capacity. - After a reschedule or a cancel, read
GET /v1/appointments/{appointmentId}with the same kind of token and compare itsrevisionandstatewith the ones you acted on.
The listing is per principal by design. A hold or appointment is owned by the verified token issuer
and subject that booked it, so another principal sees an empty page even when it serves the same
product. Book and observe under one principal. History and audit carry a pseudonym keyed with the
deployment’s audit hash key (audit.hashKeyRef) instead, which decides nothing: rotating that key
leaves every hold and appointment with the caller that booked it.
Problem codes
Section titled “Problem codes”Every Scheduling problem response carries one code from this closed catalogue, always answered with
the HTTP status listed beside it.
Two codes are reserved: eligibility.unavailable and hook.unavailable are defined, titled, and
status-pinned for synchronous decision paths. No route answers them. Hooks are asynchronous
observers, so their delivery failures never become caller problem responses;
Author a Scheduling policy
lists what a hook may send.
| Code | Status | When |
|---|---|---|
authentication.refused | 401 | The bearer credential is missing, invalid, or expired. Sign in again. |
booking.duplicate-active | 409 | An active booking already holds this party's duplicate key. Cancel it, or wait until it has passed, before booking again. |
cancellation.cutoff-passed | 409 | The cancellation cutoff for this appointment has passed. |
capability.unmatched | 422 | No backing member carries every capability the offering requires. |
capacity.exhausted | 409 | The supply is fully committed for the requested interval. Choose another time. |
cursor.expired | 410 | A page cursor has expired. Start again without a cursor and deduplicate entries by id. |
cursor.invalid | 400 | A page cursor is malformed or was issued for a different request. |
eligibility.unavailable | 503 | A required eligibility check could not run. Reserved: no milestone path answers it yet. |
hold.expired | 410 | The hold expired before confirmation. Its capacity is bookable again; start a new request. |
hold.released | 409 | The claim is not an active hold. It may already be confirmed or released. |
hook.unavailable | 503 | A required lifecycle hook could not run, so the request was refused rather than half-applied. Reserved: no milestone path answers it yet. |
horizon.outside | 422 | The requested start is earlier than the lead time allows or further ahead than the horizon allows. |
idempotency.expired | 410 | The stored response for this idempotency key has expired. For an appointment command, read the appointment by its external reference or its identifier to learn the original outcome before choosing a new key. A hold cannot be read and expires on its own, so start a new request. |
idempotency.key-reused | 409 | An idempotency key was presented for a different request. |
location.closed | 409 | A closure covers the requested start. Choose a start outside the closure. |
operation.not-authorized | 403 | The caller's task grant does not name this operation, the deployment's client does not match the grant's client, or configured fresh status says the exact grant is inactive, mismatched, unlisted, or incompletely bound. |
party.capacity-inadequate | 422 | The party is larger than the offering can ever serve, or its size falls outside the published units. |
policy.changed | 412 | The policy revision changed before the request committed. Reload the catalogue and try again with the current revision. |
precondition.failed | 412 | A source busy reconciliation does not follow the observation stream already applied for that source, or a busy record crosses the reconciliation coverage boundary. Reload the source state and send a reconciliation that observes after the latest applied one. A stale revision answers policy.changed or revision.mismatch instead. |
precondition.required | 428 | The mutation requires the revision the caller loaded, or the duplicate key the offering keys on. |
prerequisite.missing | 422 | The party is missing a prerequisite the offering requires. |
profile.not-authorized | 403 | The caller holds no profile that authorizes this request, for example a read scope check on a mutation route. |
receipt.unresolved | 409 | No retained success receipt matches the verified caller, original key, and original request. Missing, expired, or changed receipts leave the original outcome unknown; this does not prove that no effect occurred. |
request.body-too-large | 413 | The request body exceeds the accepted size. |
request.invalid | 400 | The request could not be read as a Scheduling request. |
request.method-not-allowed | 405 | The route exists but not for this method. |
request.not-found | 404 | The requested route does not exist, or the deployed policy publishes no offering by that id. The offering listing names the ones it does. |
request.unprocessable | 422 | The request body could not be processed. |
request.unsupported-media-type | 415 | The request body is not JSON. |
resource.unavailable | 409 | Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers capacity.exhausted. |
revision.mismatch | 412 | The observed revision is no longer current. Reload the current state and try again. |
schedule.unpublished | 422 | No published schedule serves that start. Choose a start on the published grid. |
service.unavailable | 503 | Scheduling cannot decide the request for now: its storage did not answer or did not acknowledge a commit, its audit destination refused the entry, the OIDC token verifier reached no verdict, or a configured task-grant authority, its response, or its service credential was unavailable or unusable. The response carries Retry-After. A commitment may already be in place: retry it with the same idempotency key after the service recovers. |
Route families
Section titled “Route families”The served routes group into these families. A family name grants nothing on its own; authority is checked per route.
| Family | Base paths | Authority profile |
|---|---|---|
| Service description | /v1/scheduling | Catalogue read |
| Catalogue | /v1/services, /v1/offerings, /v1/resources, /v1/locations | Catalogue read |
| Availability | /v1/availability, /v1/availability/explain | Catalogue read; Explain |
| Holds | /v1/holds, /v1/holds/{holdId} | Commitment |
| Appointments | /v1/appointments, /v1/appointments/{appointmentId}, its reschedule, cancel, and history | Commitment for the mutations; Catalogue read for the reads |
| Appointment receipt observation | /v1/appointments/receipt | Catalogue read, matching the exact original owner, key, and creation request |
| Liveness and readiness | /healthz, /readyz | Any caller, without a token |
/readyz answers service.unavailable while PostgreSQL is unreachable, while the schema is not
the one this release expects, and while the audit writer is not ready.
- Registry Scheduling API operations for every route, parameter, and schema.
- schedulingctl command reference for the authoring and packaging commands
that produce the policy a deployment serves, and the
plan,apply, andstatuscommands that activate it.
Cookies on Registry Stack
We use optional cookies to understand how you use the main Registry Stack website and this documentation. If you contact us, we may link this activity to your enquiry. You can change your choice at any time. Read the privacy policy.
Your browser asks sites not to track you, so optional tracking stays off.
We could not use or save that choice. Optional tracking remains off.