Skip to content
Registry StackDocsDevelopment (unreleased)

Registry Scheduling API

View as Markdown

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.

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.

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.

ProfileRoutesThe 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 historyThe configured read scope, scheduling:read by default, in the configured scope claim, scope by default
Explain/v1/availability/explainThe separately configured explain scope, scheduling:explain by default, which is never the read scope
CommitmentHold create and release, appointment create, reschedule, cancelA 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.

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.create and hold.release
  • appointment.create, appointment.reschedule, and appointment.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.

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/appointments with externalReferenceProduct, externalReferenceRecordType, and externalReferenceIdentifier. 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 its revision and state with 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.

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.

CodeStatusWhen
authentication.refused401The bearer credential is missing, invalid, or expired. Sign in again.
booking.duplicate-active409An active booking already holds this party's duplicate key. Cancel it, or wait until it has passed, before booking again.
cancellation.cutoff-passed409The cancellation cutoff for this appointment has passed.
capability.unmatched422No backing member carries every capability the offering requires.
capacity.exhausted409The supply is fully committed for the requested interval. Choose another time.
cursor.expired410A page cursor has expired. Start again without a cursor and deduplicate entries by id.
cursor.invalid400A page cursor is malformed or was issued for a different request.
eligibility.unavailable503A required eligibility check could not run. Reserved: no milestone path answers it yet.
hold.expired410The hold expired before confirmation. Its capacity is bookable again; start a new request.
hold.released409The claim is not an active hold. It may already be confirmed or released.
hook.unavailable503A required lifecycle hook could not run, so the request was refused rather than half-applied. Reserved: no milestone path answers it yet.
horizon.outside422The requested start is earlier than the lead time allows or further ahead than the horizon allows.
idempotency.expired410The 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-reused409An idempotency key was presented for a different request.
location.closed409A closure covers the requested start. Choose a start outside the closure.
operation.not-authorized403The 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-inadequate422The party is larger than the offering can ever serve, or its size falls outside the published units.
policy.changed412The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.
precondition.failed412A 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.required428The mutation requires the revision the caller loaded, or the duplicate key the offering keys on.
prerequisite.missing422The party is missing a prerequisite the offering requires.
profile.not-authorized403The caller holds no profile that authorizes this request, for example a read scope check on a mutation route.
receipt.unresolved409No 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-large413The request body exceeds the accepted size.
request.invalid400The request could not be read as a Scheduling request.
request.method-not-allowed405The route exists but not for this method.
request.not-found404The requested route does not exist, or the deployed policy publishes no offering by that id. The offering listing names the ones it does.
request.unprocessable422The request body could not be processed.
request.unsupported-media-type415The request body is not JSON.
resource.unavailable409Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers capacity.exhausted.
revision.mismatch412The observed revision is no longer current. Reload the current state and try again.
schedule.unpublished422No published schedule serves that start. Choose a start on the published grid.
service.unavailable503Scheduling 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.

The served routes group into these families. A family name grants nothing on its own; authority is checked per route.

FamilyBase pathsAuthority profile
Service description/v1/schedulingCatalogue read
Catalogue/v1/services, /v1/offerings, /v1/resources, /v1/locationsCatalogue read
Availability/v1/availability, /v1/availability/explainCatalogue read; Explain
Holds/v1/holds, /v1/holds/{holdId}Commitment
Appointments/v1/appointments, /v1/appointments/{appointmentId}, its reschedule, cancel, and historyCommitment for the mutations; Catalogue read for the reads
Appointment receipt observation/v1/appointments/receiptCatalogue read, matching the exact original owner, key, and creation request
Liveness and readiness/healthz, /readyzAny 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.