Skip to content

[Operations 1.3] A Waitlist Operations Command Is Complete Only After the API Request and Re-read

A STAFF operations screen showing a waitlist command result and the next re-read task
Clicking a button on an operations screen does not prove that an appointment was confirmed. This command-result screen shows the API outcome and the next re-read task in the same scoped view.

A STAFF member selects an OFFERED item from the action queue. The screen shows version=7 and an expiresAt that has not passed yet. But another worker may expire the offer just before the operator clicks confirm, or another operator may claim the same vacancy first. The network can also fail after the server has already created the replacement appointment.

The screen must not label every button click as success. It sends a command with the current scope and version, classifies the server result as success, replay, processing, or conflict, and then reads the relevant state again.

An operations command is a state-change contract owned by the API, not a UI event. STAFF reads the current offerRef and version, sends the command with an Idempotency-Key, then re-reads the terminal outcome decision to choose the next action.

A button on the operations screen becomes a state-change contract

Section titled “A button on the operations screen becomes a state-change contract”

The previous article treated the operations screen as an action queue rather than a state list. Selecting an item from the queue is a read. Clicking confirm or decline starts a separate write contract. Time passes between those steps, so the screen must not persist the row it had previously displayed as if it were still current.

The common path for the current staff API is:

/api/{tenantCode}/clinics/{clinicId}/waitlist

The URL exposes tenantCode and clinicId, but the authoritative permission comes from the authenticated principal’s tenant and clinic membership and capability. The request body cannot override that scope. entryRef and offerRef are opaque references whose kind and scope are validated rather than internal integer IDs. A malformed reference or one belonging to another clinic is handled as 404 WAITLIST_REFERENCE_NOT_FOUND, so callers cannot probe whether it exists.

This boundary lets the screen track not merely “which row was clicked” but “which offer in which clinic was commanded with which version.”

Re-read the current evidence before sending the command

Section titled “Re-read the current evidence before sending the command”

An action-queue row is a signal for narrowing the candidates. It is not command input by itself. The screen should pass through this read sequence first:

  1. Read the action queue from GET /offers. The list uses bounded keyset pagination with nextCursor, defaulting to 50 items and allowing at most 100.
  2. Call GET /offers/{offerRef} for the selected reference and inspect status, version, expiresAt, and deliveryState.
  3. If an earlier command may already have been processed or is still running, call GET /offers/{offerRef}/decision.
  4. Check which commands the current STAFF role may execute and only then construct the write request.
ReadWhat the screen obtainsNext decision
GET /entriesentryRef, state, and version for waitlist entriesWhether the entry can be withdrawn or opened for offer details
GET /offersOffer list and nextCursorRecompute action-queue priority
GET /offers/{offerRef}status, version, expiresAt, and deliveryStateWhether a command can be sent for the current offer
GET /offers/{offerRef}/decisiondecisionState, appointmentRef, and decidedAtReflect an already processed command in the screen

Reading version=7 does not guarantee that the server is still at version 7. The version is a condition for attempting the write. The re-read explains what that attempt actually produced. Those roles must not be collapsed into one row object that mixes an old screen with current server state.

STAFF re-reads the offer and evidence, sends a confirmation API request with a version and idempotency key, and receives success, replay, processing, or conflict from the database fence
The command does not end at one success line. The server distinguishes success, replay of the same request, an in-progress request, and version, expiry, or occupancy conflict; the screen connects each result to a re-read and an action-queue state.

The request carries scope, version, and idempotency together

Section titled “The request carries scope, version, and idempotency together”

A confirmation request can look like this. The example contains no patient data or internal integer ID.

POST /api/clinic-a/clinics/42/waitlist/offers/o_dGVuYW50.../confirm
Idempotency-Key: staff-confirm-20260815-01
Content-Type: application/json
{
"expectedVersion": 7,
"confirmationSource": "FRONT_DESK"
}

Every waitlist mutation requires an Idempotency-Key made of 16–128 printable ASCII characters. Repeating the same key with the same request must return the first result instead of creating another appointment. Reusing the key with a different request body is a conflict; two different commands must not be silently merged.

expectedVersion passes the version read by the screen as a write condition. The server locks and checks the offer, waitlist entry, hold, policy decision, and appointment capacity again. If any prerequisite has changed, it returns a conflict rather than forcing the old screen intent onto the current state.

Values such as confirmationSource explain the operator action, but they do not provide tenant, clinic, or member scope. Scope comes from the authenticated principal and the URL. Public responses and logs should keep correlation IDs and stable reason codes, not raw personal details.

WaitlistApplicationService.confirmOffer does not treat confirmation as one long HTTP operation. The current application service separates it into three stages:

StageWhat is storedMeaning after a failure
1. Idempotency reservationCommand scope, key digest, request digest, and PROCESSING stateDecide whether the same key is already running
2. Business transactionOffer claim, replacement appointment, and capacity-hold consumptionPreserve expiry, stale-version, or slot-occupancy conflict as the original outcome
3. Completion recordSuccessful appointmentId or a stable failure codeReplay the same result after a network retry

This separation matters because the process can stop immediately after creating the appointment. If stage 2 commits and stage 3 has not recorded the result, the command remains PROCESSING. The next request must reconcile the existing replacement appointment before it attempts anything that could create a second one.

WaitlistApplicationServiceTest verifies this boundary. Calling the same command twice invokes the claim, replacement appointment creation, and hold consumption once; retrying after a failure just before the success record replays the existing appointment. That proves the application-service idempotency boundary, not that the feature is enabled for every clinic.

Each HTTP result requires a different operator action

Section titled “Each HTTP result requires a different operator action”

The waitlist delivery API contract does not collapse confirmation into one generic 200 OK. Each result has its own retry and re-read rule.

API resultMeaningNext action on the screen
201 + appointmentRefA replacement appointment was created and the offer became ACCEPTEDShow success and re-read GET /offers/{offerRef}/decision
201 + Idempotent-Replay: trueThe successful result for the same key and request was replayedShow that no new appointment was created and reflect the same decision
202 IDEMPOTENCY_IN_PROGRESS + Retry-After: 1The same command is still processingWait one second, re-read the decision, and do not create a duplicate command with a new key
409 OFFER_EXPIREDThe offer expired before the write could commitRe-read the offer and vacancy, then route to the next candidate or staff review
409 DECISION_STALEThe policy decision or version read by the screen is staleRe-read the current decision and version before deciding whether to send a new command
409 SLOT_OCCUPIEDAnother command occupied the vacancy firstDo not display success; retain the conflict evidence in the action queue

202 is not a failure. It means that the outcome is not known yet. Ignoring Retry-After and sending confirmation with a new idempotency key is the dangerous path. A 409, by contrast, is an explicit boundary saying that the screen’s precondition is no longer valid.

The clinic-appointment API document describes these typed reason codes as the contract, while the common WaitlistApiError enum in the current adapter also contains a general WAITLIST_CONFLICT. A client should not invent branches for strings it has not observed in the deployed response schema. Verify the actual reason-code mapping for the release being operated. If the detailed design and the HTTP adapter differ, the article should say so rather than hide the drift.

Reference errors and temporary outages follow the same separation:

ResultRetry rule
404 WAITLIST_REFERENCE_NOT_FOUNDRe-read the current list and use the latest server-issued offerRef
400 INVALID_IDEMPOTENCY_KEY or PAYLOAD_INVALIDFix the request and send a new request; do not retry automatically
503 WAITLIST_UNAVAILABLE + Retry-AfterRetry the same intent with the same idempotency key after the indicated interval

Decline follows the same read, command, and re-read order

Section titled “Decline follows the same read, command, and re-read order”

Decline is the opposite button from confirm, but it is not a local status=DECLINED edit. POST /offers/{offerRef}/decline also requires an Idempotency-Key and expectedVersion, and its body carries a bounded reasonCode.

The server releases the hold associated with the offer and records the decline. It should not create a new offer for the next waitlist entry until the release has committed. The screen should not mark the next candidate as successful just because it received 200; it should re-read GET /offers and GET /offers/{offerRef}/decision and verify that the decline, resource release, and next action are visible.

Without this order, two operators can decline the same offer while the next candidate still sees a held vacancy, or two offers can be created for one vacancy. Decline is also a state-change boundary and needs the same scope, version, and idempotency rules as confirmation.

A re-read is a decision boundary, not a browser refresh

Section titled “A re-read is a decision boundary, not a browser refresh”

Representing the operations flow as a full-page browser refresh makes it impossible to tell which request produced the displayed result. Read only the resources needed for the next decision:

Read action queue
→ Read offer detail and decision
→ Send confirm/decline command
→ Classify the HTTP result
→ Re-read the offer decision
→ Show terminal state or staff review in the action queue

Each step has a different responsibility:

  • Action-queue read decides what to inspect first.
  • Offer-detail read obtains the version and expiresAt for the command.
  • Command request delegates the state change to the database fence and idempotency record.
  • Result classification distinguishes success, replay, processing, and conflict.
  • Decision re-read makes the screen reflect current server state.

After a 202, the screen should wait for Retry-After and read the decision instead of guessing that a disabled button means success. After a 409, it should not retry forever; it should read the current offer and queue evidence and choose the next action.

A re-read does not end in a browser refresh. The Dashboard connects that result back to the action queue so staff can choose the next piece of work.

Separate STAFF commands from ADMIN policy changes

Section titled “Separate STAFF commands from ADMIN policy changes”

This series is centered on the STAFF operations view. STAFF can read the current offer and, within its capability and scope, confirm, decline, or withdraw it. Policy activation, restriction adjustments, recovery credits, and benefit changes belong to separate ADMIN capabilities and APIs.

Putting both roles’ commands in one button group hides the permission boundary. A policy command that is not visible to STAFF should not become an unexplained hidden feature; the screen should show the scope of state changes available to the current role. Developers must enforce capability and scope again in the API. Button visibility is not a security boundary.

Keep current implementation, API contract, and readiness separate

Section titled “Keep current implementation, API contract, and readiness separate”

These three layers must not be flattened into one claim.

CategoryVerified in this article
Current implementationWaitlistController entry and offer reads plus confirm, decline, and decision routes; request DTO expectedVersion; response DTO version, expiresAt, and appointmentRef; and WaitlistApplicationService idempotency reservation, replay, and recovery tests
API contract and approved designScoped opaque references, a 16–128-character Idempotency-Key, bounded keyset cursor, 201 replay, 202 processing, 409 conflict, and separate notification delivery and acceptance
Operational readinessActual clinic allowlist, notification-provider failure rate, canary evidence, reconcile and recovery drills, and the reason-code mapping of the deployed version

Code and tests prove that the application boundary is prepared. They do not prove that a particular clinic has enabled the feature or that operators have observed every result on a real screen. Before rollout, verify the feature flag, capability, response headers, Retry-After, and re-read behavior against the deployed version.

A button click must return as a terminal outcome decision

Section titled “A button click must return as a terminal outcome decision”

An operations screen should explain the precondition and result of a command, not merely send it quickly. The sequence STAFF needs to see is:

  1. Confirm which clinic scope and offer are being handled.
  2. Read the current version, expiresAt, deliveryState, and decision.
  3. Generate an Idempotency-Key that will not create a duplicate appointment when the same intent is retried.
  4. Send confirm or decline with expectedVersion.
  5. Classify success, replay, processing, or conflict and re-read the decision.
  6. Leave the terminal state or staff-review reason in the action queue.

With this order, the screen can distinguish “the button was clicked” from “the appointment was confirmed.” The next article will connect these outcomes to bounded retry and recovery policy in the action queue.

Comments

Leave a note or reaction with your GitHub account.