Skip to content

Orchestra integration

Orchestra is a lightweight, plugin-based workflow engine. Booking pairs with it naturally: Booking owns capacity, Orchestra owns the process (the steps, timers and approvals around a reservation). The yoyaku_orchestra submodule provides the glue.

Task types

yoyaku_orchestra ships Orchestra #[TaskType] plugins that call the booking API as workflow steps:

Task type Calls Use
Hold hold() Reserve capacity when the process starts.
Confirm confirm() Commit on approval / payment.
Release release() Free capacity when somebody cancels.

And, for a step whose deadline is the basket's, one deadline provider and one timeout action:

Plugin Does Use
yoyaku_basket_deadline Answers when the order's basket window runs out. As a step's deadline.
yoyaku_basket_lapsed Expires the order, if its window has really run out. As that step's task timeout action.

The hold clock is the order's

An instance does not take the basket's clock, and there is no step that lets it. The window belongs to yoyaku_transaction.hold_expires and stays there for the whole run, which is what lets it be renewed as the basket changes even after the workflow has started - a booking added at the payment step re-arms it exactly as one added before the form did.

The invariant: a step may carry a deadline derived from the basket's, and may observe that it has passed, but never decides it. A wait node whose window is the basket's names yoyaku_basket_deadline as its deadline, and the provider is asked as the step parks. An order that is off the clock - a realm stating no window, a checkout holding it frozen, an order holding nothing - answers nothing, and a step deriving its deadline from nothing parks without one and waits to be resumed. That is the right reading of all three: none of them ends by running out of time.

Because it is asked rather than read, no step exists to keep it current and nothing goes stale between computing it and waiting on it. A basket renewed while the step waits moves the deadline, and the step is asked again the next time its timeout fires without resuming it, so it follows the window rather than being left with none. The timer is still an optimization, not the authority: it may wake early, find the basket alive and disarm, and the real lapse is caught by the sweep.

yoyaku_basket_lapsed is the timeout action, and it decides nothing. It calls the one expireTransaction() that cron calls, which re-reads the state and the deadline under the order's row lock and does nothing at all if the basket is still running. So a step that wakes early because the basket was renewed under it costs that basket nothing.

Whoever notices a lapse - the cron sweep, the step's own timeout, an operator emptying the order - goes through that one function, so the end state cannot depend on who got there first. A site whose cron runs every ten minutes ends a lapse exactly as a site whose cron runs every minute does.

Ending the instance is BookingReconciliationSubscriber's, on the EXPIRED the expiry announces. Where a step is waiting on the basket it resumes that step with an expired outcome, so the model routes the lapse on a branch anybody can see; where nothing is waiting, because the instance is past that step or between two, it tears the instance down as every other terminal transition does. One subscriber decides, so a resume and a cancel cannot race each other.

An instance that ends still owes the units a release, and BookingTerminationSubscriber covers the instance that is canceled or fails. A lapse needs no such backstop: the bookings are already expired by the time the instance is torn down, so the releaser finds nothing held and stands down.

Nothing of this reaches the engine: yoyaku announces that a booking was held and knows nothing of workflows.

Only a decision reaches the release

The shipped model sends neither timeout to yoyaku_cancel_order.

A form step that lapses ends at its own end node. A basket running out is not a cancellation: nobody decided anything, the order ends empty and stays reusable, and the instance ends normally, which also gives "your basket expired" somewhere to be said.

A payment step whose window lapses returns to the form. Nobody said they had finished with it either, and the release rides on the payment's own event, which restores the window the checkout suspended verbatim - so a booker who had forty minutes left when they went to pay comes back with forty minutes and a basket still intact. The dead case needs no special handling: the form step parks on a deadline already past, the sweeper fires it, and the instance leaves by the one expiry route.

So n_release is reached only by somebody choosing to cancel.

Order variables

Almost every branch in a booking workflow routes on something derived from the order: how it settles, whether it may be self-canceled, what guarantee it takes. None of that is baked at start, because the basket can still change afterwards. It is computed at a variables node (Orchestra's task type of that name) placed once the order is final, just before placement, so each branch reads the order as it stands there.

Yoyaku ships one VariableProvider plugin per rule, each resolving the instance's order from the instance:

Provider Writes Meaning
yoyaku_arrival yoyaku_arrival Earliest slot start across the active bookings; anchors the guarantee and cancellation timers.
yoyaku_auto_confirm yoyaku_auto_confirm Whether the order confirms itself or waits for operator validation.
yoyaku_settlement yoyaku_settlement The settlement mode shared by every active booking, when they agree.
yoyaku_cancellation yoyaku_allow_cancellation, yoyaku_cancel_deadline Whether the booker may self-cancel, and until when.
yoyaku_guarantee yoyaku_guarantee_amount The held guarantee total, when the order carries one.

A node selects the providers it wants, so a model takes only the variables it branches on, and the workflow config records which modules it depends on. A provider whose rule does not apply to this order contributes no variable at all: the branch gated on it is simply not taken. An instance that carries no order at all stops at the node with an incident instead (see Subprocesses). See Computed variables in the Orchestra documentation for the mechanism.

Subprocesses

Say the booking workflow for order 42 calls a "Collect the guest list" subprocess, which shows a booking form and confirms the order. Embedded, the subprocess runs inside the booking instance and shares its variables, so it knows the order. Run as an instance of its own, it starts with only the variables the subprocess node passes in. So pass the order down: add a line yoyaku_transaction_id to the node's input variables.

Nothing is passed down on its own, and that is deliberate. You choose which subprocesses act on the order, and a subprocess on another site never receives an id that means nothing there.

A booking step that runs in an instance carrying no order cannot do anything right. A confirm would confirm nothing, and a gateway would branch on variables nobody computed. So the step stops with an incident naming the variable to pass down, rather than letting the workflow go on as if it had worked. The booking steps are the order actions, the payment actions, the variables providers above and the basket deadline. The payment page is the exception: a booker is looking at it, so an instance with no order shows it with nothing to pay.

A branch can work on another order than its instance: when its token carries yoyaku_transaction_id as a variable of its own, the booking steps and the order pages on that token act for that order, as Orchestra resolves the variable for them. The booking form does the same: it checks the policies of, and captures the answers onto, the order its step's token sees.

The order keeps one owner: the instance it started with. That is the instance the order's signals and links reach, the one deleted with the order, and the only one whose cancel or failure releases the order's places. A subprocess given the order's id acts for it without taking it over, and so does any other workflow started with that id while the owner runs. Once the owner has ended, the next instance started with the id becomes the owner.

When the order is confirmed, the booking form submissions of every instance acting for it are sealed alike: the owner, a subprocess given its id, and any other workflow started with it.

Example: payment-gated reservation workflow

flowchart TD
    START([Start]) --> FORM[User task: fill form<br/>⏱ the basket window, asked as it waits]
    FORM -->|submitted| PAY[Payment task<br/>⏱ the payer's window]
    FORM -->|expired| LAPSE[The basket lapsed]
    FORM -->|canceled| REL[Release]
    PAY -->|paid| CONF[Confirm booking]
    PAY -->|expired| FORM
    PAY -->|back| FORM
    PAY -->|canceled| REL
    CONF --> DONE([Confirmed])
    LAPSE --> EXPIRED([Expired])
    REL --> CANCELED([Canceled])

The form step's deadline is the basket window, and it is asked for as the step waits, through the yoyaku_basket_deadline deadline provider. The window belongs to the order rather than to the instance - a booker who adds a place renews it - so a value recorded once and carried in the model would describe a moment that has since moved. Asking also means the step can be asked again: one woken on a window the booker has extended waits on the new one instead of being left with no deadline at all. The payment step's window is its own, because it sits in the locked phase where nothing refreshes: locking suspends the basket window and unlocking gives back that same timestamp, so a lapsed checkout returns the booker to a basket with exactly the time it had left.

Neither step releases anything itself. The payment step's timeout runs payment_lapsed, which makes the payment lapse for real, and the release rides on the payment's own event, exactly as it does when the reaper notices first. That is why the diagram shows no unlock step: it would be one half of a bracket whose other half, taken when the payer was handed off, was never drawn. Only an explicit cancellation reaches Release.

Both roads back from payment return straight to the form, because there is no number to refresh on the way. This is the two-phase payment flow expressed as a workflow - the booking engine stays phase-agnostic while Orchestra sequences the phases.

Payment workflow actions

yoyaku_orchestra_payment exposes the guarantee operations as Drupal Action plugins, so each is a step you drop into an Orchestra action node (the orchestra_action task type) and configure in the modeler. The action resolves the order from the running instance (which carries the transaction id as the yoyaku_transaction_id process variable) and delegates the money operation to Kessai's payment client, so the gateway stays the engine's concern.

Action Does Key settings
Authorize guarantee Holds the guarantee, writing yoyaku_guarantee_outcome (held / declined / none) for branching. gateway
Claim guarantee (damage) Keeps the whole guarantee, or a set amount, and gives back the rest. gateway, amount
Release guarantee Gives the guarantee back, keeping nothing (or an amount set on the node). gateway, keep
Claim the no-show fee Keeps the resource's no-show fee out of the guarantee and returns the rest. gateway

Every action takes a gateway setting (the gateway plugin id, e.g. worldline or manual); the claim and release steps add an amount. The no-show fee amount is not on the node: it is the resource's noshow_fee field (resolved per booking, summed across the order), so the fee is a pricing decision that lives with price and the guarantee, not in the workflow. The attendance decision itself is an ordinary user task (outcomes attended,damaged,no_show), and these actions hang off its branches.

Deferring the hold with an Orchestra timer

A guarantee covering damage only needs to be valid during the stay, and a card authorization expires in roughly 7 to 30 days, so for a booking made months ahead a hold placed now would lapse before arrival. The hold is therefore deferred with a Wait node + an Orchestra "until" timer, not a separate cron: the variables step produces a yoyaku_arrival process variable (the earliest slot start), and the wait's timeout is configured until: yoyaku_arrival with until_offset: -P3D (three days before). Orchestra's own timer resumes the wait near arrival and the flow runs Authorize guarantee; a short-lead booking whose date has already passed simply fires at once. What bridges the long gap between booking and that hold is the card token captured at checkout. See the guarantee and gateway design for the full rationale (tokenization, deferred holds, no-show, re-authorization).

The shipped example

Every exit taken after a payment has succeeded shares one path, so the money is always handed back the same way: notify, refund, cancel the order. Two outcomes reach it, an operator rejecting the booking and a confirmation that fails (an order constraint violated by a race between the hold and the confirm), and the second joins after the notification because the confirm action has already told the booker itself. The exits that mean no payment ever succeeded, the registration form and the payment step timing out or being canceled, go straight to canceling the order: there is nothing to give back.

None of those paths releases a guarantee, because none can hold one: the branch that authorizes it is reached only through a successful confirmation. The release belongs to the cancellation path, which runs after confirmation, and is there.

The yoyaku_orchestra_example yoyaku_booking workflow wires these in after an order is confirmed:

flowchart TD
    CONF[Confirm order] --> PREP[Compute refund deadline]
    PREP --> FORK{{Fork}}
    FORK --> CW[Await cancellation<br/>timer: until slot start<br/>booking_cancel interaction]
    FORK --> WAIT[Await guarantee window<br/>timer: until arrival -P3D]
    WAIT --> AUTH[Authorize guarantee]
    AUTH -->|declined| MGR[Notify managers]
    AUTH -->|held / none| DONE[Guarantee in place]
    MGR --> DONE
    CW -->|canceled / timeout| JOIN{{Quorum join}}
    DONE --> JOIN
    JOIN -->|not canceled| ATT[Record attendance]
    ATT -->|attended| MC[Mark order completed] --> REL[Release guarantee]
    ATT -->|damaged| MCD[Mark order completed] --> CAP[Claim guarantee]
    ATT -->|no-show| MU[Mark order unused] --> FEE[Claim no-show fee]
    FEE --> OK([End: confirmed])
    REL --> OK
    CAP --> OK
    JOIN -->|canceled| RH[Release hold] --> CO[Cancel order] --> RG{Refund due?}
    RG -->|yes| RV[Operator: approve refund]
    RG -->|no| NC[Notify: canceled]
    RV -->|approve| RF[Refund] --> NC
    RV -->|deny| NC
    NC --> KO([End: canceled])

Once confirmed, the instance forks into two concurrent branches that rejoin at a value-aware (quorum) join:

  • the guarantee branch: a timer waits until near arrival, then holds the guarantee; a declined hold routes to a manager-notification task. With the manual gateway this is a safe no-op; with Worldline it places a real hold;
  • the cancellation branch: an interaction the booker reaches from a link in their confirmation email (see below). Cancellation is always allowed, right up to the slot start.

Fulfillment (recording attendance, then releasing or claiming the guarantee) happens after the join, on the not-canceled path. Because the join resolves only once the cancellation window has closed at the slot start, a booking can never be both attended and still cancellable: the point of no return strictly follows the window, and canceling an already-attended booking is impossible by construction.

Recording attendance also sets the order's terminal state: attended and damaged mark it completed, a no-show marks it unused. These are order and booking states in their own right (alongside pending, confirmed, partial and canceled), so a fulfilled booking is told apart from a no-show long after the slot. Dropped bookings never count against them: an order stays completed even when the holder canceled some of them.

If the booker cancels, the join fires early and tears down the guarantee branch (voiding any authorized hold via a release-hold step), then cancels the order and runs an operator refund review only when a refund is due (a real payment was captured and the cancellation is within the refund deadline). If the window instead times out at the slot start without a cancellation, the instance continues down the guarantee branch to its confirmed end.

Booker notifications

The workflow's "Notify about an order" action hands the milestone to yoyaku_order's notifier, which sends the booker email exactly as it does for an order placed without a workflow: see Booker emails for the templates, the tokens and when the email goes. The instance's yoyaku_refunded_amount makes the message mention a refund, and its orchestra_notification_template_suffix asks for a template variant.

Staff steps (operator validation, guarantee-hold-failed, attendance, refund review) are separate: they notify the resource managers, not the booker. Each is an interaction_task carrying the order_review interaction, so the operator is shown the order (its summary, and any constraint-policy violations) before choosing an outcome, rather than deciding blind. Because the review is an interaction, the same screen works whether the step is reached from the inbox, a pull operation, or a signed link.

Canceling a confirmed booking

A confirmed booking is canceled two ways, both running the graceful workflow path (guarantee release, refund gate, cancellation notice) rather than a blunt state change. They are the two arms of a cancel fork feeding one quorum join: the first arm to signal canceled wins, and if all arms time out the booking stays confirmed, so neither can double-cancel.

Booker self-cancel. The confirmation email carries the cancel link. Because the link is a signed, token-scoped URL that only exists once the cancel step has parked, the confirmation is sent when that step opens (a TokenParkedEvent subscriber), and it renders a "Cancel booking" button plus the clickable link. Clicking lands the booker on a confirmation screen (with the order summary, and a notice when the cancellation is past the refund deadline) rather than canceling on arrival, so an email client prefetching the link cannot cancel a booking; only the explicit confirm signals canceled. The booker may self-cancel until the resource's cancellation_deadline (+ unit): the workflow resolves it (earliest slot start minus the strictest resource cutoff) into a yoyaku_cancel_deadline variable and the booker wait times out then, so a link replayed after the cutoff hits a token that is no longer parked and is a no-op. A zero cutoff keeps self-cancel open to the slot start.

Operator graceful cancel. An operator assigned to the resource (the resource_managers audience, which yoyaku_orchestra_manager ships) can cancel up to the slot start through the n_cancel_window_operator node, a comment_operation that requires a cancellation comment before it records canceled. This is the graceful, workflow-driven cancel; it is distinct from the admin full/forced cancel in the orders operations column, which hard-cancels outside the workflow.

The refund gate is separate. refund_deadline (+ unit) decides only whether a cancellation is refunded (resolved into a yoyaku_refund_deadline variable): canceling before it is refunded, later is allowed but not refunded. cancellation_deadline bounds whether the booker may still self-cancel; refund_deadline bounds whether the cancellation is refunded. They are independent, so a resource can, say, refund up to two days out yet stop online self-cancellation five days out.

Surfacing the operator window. The orders overview shows a Workflow column listing the current operator's assigned operations for each order (the graceful "Cancel booking" when assigned and before the slot starts), read from orchestra_ui's pending-actions surface and kept separate from the standard lifecycle operations column so the graceful cancel is never confused with the admin full cancel. The column is contributed by yoyaku_orchestra_order through the hook_yoyaku_order_overview_columns / hook_yoyaku_order_overview_cell extension point, which any module may use to add its own order column. Before any cell is built, hook_yoyaku_order_overview_prepare hands the module the whole page of orders. The Workflow column uses it to read the operator's actions on every order of the page in one query, instead of one query per row.

Overriding an email template

The booker emails are yoyaku's (see above): yoyaku_order_easy_email sends the first of yoyaku_<tenant>_<type>_<suffix>, yoyaku_<tenant>_<type>, yoyaku_<type>_<suffix> and yoyaku_<type> that exists. The rest of this section is about the workflow's own notifications, such as a task assigned to an operator.

orchestra_easy_email resolves a template with a Drupal-suggestion-style cascade, most specific first, over the instance tenant, the workflow, the notification type, and an optional suffix:

orchestra_<tenant>_<workflow>_<type>_<suffix>
orchestra_<tenant>_<workflow>_<type>
orchestra_<tenant>_<type>_<suffix>
orchestra_<tenant>_<type>
orchestra_<workflow>_<type>_<suffix>
orchestra_<workflow>_<type>
orchestra_<type>_<suffix>
orchestra_<type>
orchestra_generic

Empty dimensions are skipped and the first existing template wins. The tenant comes from the instance, so per-tenant (for example per-domain) templates need no wiring: ship orchestra_<tenant>_booking_confirmed and it is used for that tenant's bookings. The suffix comes from an orchestra_notification_template_suffix process variable for any other per-context variant (orchestra_generic_vip). Everything falls back to the plain orchestra_<type> default with no code.

Division of responsibility

flowchart LR
    subgraph O[Orchestra]
        P[Process / steps / timers / approvals]
    end
    subgraph B[Booking]
        CAP[Capacity / holds / availability]
    end
    P -->|hold / extend / confirm / release| CAP
    CAP -->|lifecycle events| P

Booking can also react to nothing in Orchestra and simply be driven by it; or a booking event (e.g. CONFIRMED) can in turn signal an Orchestra token. The two compose in either direction.

Where a booker notification is rendered

A booking is made on one surface and confirmed on another: an operator validates from a back office, cron releases a hold with no request at all, a queue worker mails hours later. Everything the confirmation is built from would otherwise resolve against whichever request is running, so the cancel link, the site name and the ticket PDF's branding would be that of the surface doing the work rather than the one the booker booked on.

Orchestra records where each instance started, and the order notifier dispatches the whole booker notification there. That single wrap covers more than the message: an attachment enricher runs during the same dispatch, so the ticket PDF, its QR code and its logo follow too.

Operator-facing notifications deliberately do not follow the booking. A task waiting in an inbox links to the inbox, wherever that is; Orchestra calls that place the workspace. See Orchestra's own notification documentation for how the two places are configured, and install Orchestra Domain on a Domain Access site to give them meaning.