User picks a hotel, the app drafts a reservation, finance approves bookings over $500, the vendor is called with an idempotency key, and the user can cancel within a 24-hour window that triggers the vendor cancel endpoint.
End-traveler flow from hotel selection to confirmed reservation, with a conditional finance approval branch and a post-confirmation cancellation window.
"Done" for the user is the booking artifact (confirmation page + email with vendor confirmation number) plus a visible 24h cancel button. "Done" for the org is a settled domain_effects row for book_hotel and an audit trail tying the reservation to the finance approval (when applicable).
The workflow starts when the user taps Confirm on a draft they already shaped in the app.
ingress = user_request (in-app action, not a webhook)command_type = hotel_reservation.confirmidempotency_key = "confirm_booking:{draft_id}" — the draft is the dedup anchor; a double-tap on Confirm hits the same rowrequested_by = user_id from the sessioncontext = { workspace_id, user_id, app_id, trace_id }The draft itself is created by a prior lightweight command (hotel_reservation.draft) with its own idempotency key keyed on (user_id, hotel_id, check_in, check_out). The Confirm command depends on the draft via command_dependencies.
hotel_reservation.draft — sync, validates inputs, creates the draft artifact, returns to UI.hotel_reservation.confirm — async, the main workflow. Depends on the draft command via command_dependencies (parent required_status = succeeded, propagate_cancellation = true).command_id uuid
command_type hotel_reservation.confirm
requested_by user_id
ingress user_request
payload { draft_id, reason, accepted_terms_at }
context { workspace_id, user_id, app_id, trace_id }
status created → validated → queued → running → ...
cancellation_mode compensate_then_stop
idempotency_key confirm_booking:{draft_id}
dbos_workflow_id set on enqueue
Status path: created → validated → queued → running → succeeded on the happy path. On user-initiated cancel inside the 24h window: running → cancelling → compensating → compensated → cancelled. On policy denial or approval rejection: failed.
Policies are composed in order on the confirm command. The first non-allow decision short-circuits the rest (except where noted).
permission — user must hold the book_travel capability in their workspace.cost — per-user / per-workspace daily cap on hotel spend; hard deny on overflow.approval_requirement — if total_amount > $500 returns require_approval with reviewer group = finance_approvers.external_sharing — we send guest name + dates to the vendor; check that the workspace allows third-party PII egress.destructive_action — tagged because a successful book costs real money and the inverse (cancel) is time-bounded by the vendor.rate_limit — bucket per user (e.g. 5 confirms / hour) to defang accidental loops in the app.connector_scope — the vendor connector must declare both book and cancel capabilities before this command can register.Async deterministic plan. Sync prelude validates and creates the command record; the bulk runs on durable queues.
Execution modes used: sync_function, connector_call, human_task, external_job (the scheduled window-close timer).
Effects are declared upfront from CoreResult.effects and inserted as domain_effects rows in planned state; the runtime adapter walks them.
| effect_type | side_effect? | idempotency_key | compensation | counter_pure? |
|---|---|---|---|---|
hotel_booking.book |
yes (vendor mutation, payment) | book_hotel:{draft_id} |
cancel_reservation |
yes (vendor confirms full reversal inside window) |
notification.user_email |
yes (external send) | notify_booking:{command_id} |
send_cancellation_email |
no (new email, not an unsend) |
The vendor's idempotency window may be shorter than ours. Effect retries past the vendor's TTL would create a duplicate booking. Pin RetryPolicy.max_attempts for connector_call to a value that fits inside the vendor's documented idempotency window.
Lightweight, opt-in. The workflow itself doesn't require memory to function; it's an enhancement.
scope = usertype = preference — preferred hotel chain, room type, bed type.type = approval_preference — finance team's running notes on a requester (e.g. "approved for client travel through Q3").memory_consent policy allow before commit.memory_supersede audit row.booking_draft — type connector_snapshot, status draft → validated → archived. Created during the draft command.booking_confirmation — type report, status created → published. Includes hotel, dates, total, vendor confirmation number, and the 24h cancellation deadline.approval_packet — type approval_packet, only produced when the approval branch fires.All three are Postgres pointers; no large blobs to push to object storage. Versioning: each confirmation is a single immutable row; a cancellation produces a new booking_cancellation artifact rather than mutating the original.
Single conditional gate. Fires only when total_amount > $500. Reviewer group: finance_approvers. First member to act wins.
approval_requirement (threshold: $500)book_hotel effect against the vendorrejected by defaultOn rejected or expired: command transitions to failed, no effects executed, user gets a rejection email. No alternate path — the user can re-draft.
Not agentic. The plan is deterministic; no AgentRun or SwarmRun is involved.
If a future iteration adds a "concierge agent" that suggests upgrades or alternate hotels, it would sit before the draft command, scoped to allowed_tools = [search_hotels, retrieve_memory] and allowed_connectors = [vendor_search, llm_provider]. The confirm workflow itself stays deterministic.
Single append-only domain_events table. All rows here use purpose = audit unless noted.
Business stream (purpose = event) emits booking.confirmed, booking.cancelled, booking.rejected for downstream consumers (analytics, finance ledger).
cancellation_mode = compensate_then_stop. The vendor booking is real money and the user is promised a 24h reversible window, so a clean undo is non-negotiable.
Two distinct cancel paths exist:
book_hotel succeeds, e.g. during approval wait) — nothing to compensate at the vendor; the approval row is marked cancelled, command exits at cancelled.hotel_reservation.cancel command that triggers the compensation chain on the original.Status transitions on the original command: succeeded → cancelling → compensating → compensated → cancelled. propagate_cancellation = true on command_dependencies means a draft-side cancel never gets here in production, but it's defensive correctness.
Two-effect chain, each with a declared inverse. The graph validator at startup confirms no cycles, depth = 1, and both compensations declare of targets that exist.
Order of compensation walk is reverse of planned effects: notify-cancellation runs after the vendor cancel succeeds, so the user is never told the booking is cancelled if the vendor leg fails.
cancel_reservation declares counter_effects = true; the runtime drift detector compares emitted effects against the declared inverse set and writes a compensation_drift row if they diverge. send_cancellation_email declares counter_effects = false — it's a new outbound email, not an inverse of the original send.
DBOS for v1. The compensation chain has depth 1 and is orchestrated by Concord as a sub-workflow, so SAGA_COMPENSATION_NATIVE isn't required.
| operation | retryable | max_attempts | backoff_seconds | requires_idempotency_key |
|---|---|---|---|---|
book_hotel | yes (transient only) | 3 | 2, 6, 18 | yes |
cancel_reservation | yes (transient only) | 5 | 1, 3, 9, 27, 60 | yes |
notify_user | yes | 4 | 1, 3, 9, 27 | yes |
wait_for_approval | no | 1 | — | n/a |
vendor_hotel_api — capabilities: book, cancel; auth: OAuth client_credentials; idempotency: native, header X-Idempotency-Key.notification_provider — capabilities: send_email; idempotency: client-supplied message_id.require_approval at $500.01, allow at $500.00.confirm_booking:{draft_id}.domain_effects rows in planned.pending → approved via signal; workflow resumes.book → cancel_reservation at vendor, command compensated → cancelled.book_hotel effect at vendor (idempotency works).book_travel capability — command fails before any effect runs.failed, no effects, rejection email sent.vendor.modify (not in scope) is rejected at registration.compensation_drift row appears.memory_candidates.concord_boundary_check.py rejects dbos / temporalio imports outside the runtime adapter file.For every confirmed booking, exactly one book_hotel effect row reaches succeeded with a vendor confirmation number, and the user sees the confirmation artifact within 30 seconds of approval (or immediately, in the no-approval path). For every cancel inside 24h, exactly one matching cancel_reservation effect reaches succeeded.
max_attempts accordingly or switch to a slower backoff inside the TTL.quorum — out of scope for v1 but flagged.cancel_reservation is generous (5 attempts) but if the vendor stays down, the user thinks they cancelled while we have a live reservation. Need an alerting path on compensation.failed.external_sharing policy needs to be wired to the workspace's data residency settings; not all customers may allow guest names to leave the org.WORKFLOW_VERSIONING is not declared by the DBOS adapter; if the payload schema changes, in-flight commands need a migration path. Revisit when scaling beyond a single deploy.approval_callback ingress on a sibling command.