Plakla · internal docs / G.7 · reservations / shipped 2026-05-17

Reservations,
durable.

The reservation lifecycle is now a per-reservation Temporal workflow with durable timers and signals. No scan-and-act crons. No inline email sends gating user-facing responses. A customer reserves → the workflow takes over → the store member acts → email fires after each transition → the workflow exits at terminal state.

6
Phases
25
Tasks
22
SQL queries
4
Lifecycle tests
0
Polling crons
01 · Architecture

One workflow
per reservation.

Stable workflow ID reservation-<uuid>. Every state mutation flows through a single guarded TransitionReservationActivity — status-checked UPDATE plus an audit row, both in one transaction. Returns Applied=false when the guard rejects, so replays and stale signals are no-ops.
start ExecuteWorkflow PENDING 24h TTL CONFIRMED 48h TTL Terminal expired rejected fulfilled cancelled confirm signal 24h timer · expire reject signal cancel signal · user or store 48h timer · expire fulfill signal cancel signal 47h reminder · pickup email
Stable id
reservation-<uuid>
Pending TTL
24h
Confirmed TTL
48h
Pickup reminder
−1h before expiry
02 · Timeline

Six phases.
One day.

Each phase is independently shippable. v0.13.0 cut the first user-facing release at G.7.2; every subsequent phase rolled through the existing Argo CD + Kargo pipeline with no manual kubectl apply.
G.7.1
Workflow + activities
8bc5e12 ReservationWorkflow state machine. Guarded TransitionReservationActivity. 4 lifecycle tests via testsuite.WorkflowTestSuite — fast-forwards 48h+ in 0.8s.
G.7.2
Reserve flow
b2a6261 POST /:locale/listing/:publicId/reserve on listing detail. HTMX form swap. Get-or-create users by email from Kratos session. v0.13.0
G.7.3
/me views + cancel
56a5585 Tabbed index, detail with reservation_events timeline, cancel via SignalWorkflow. Wrong-user attempts 404. v0.14.0
G.7.4
/admin queue + 4 actions
4f3422c Three-section dashboard. Confirm / Reject (modal) / Fulfill / No-show. Membership-scoped via SQL. v0.15.0
G.7.5
Resend + 3 templates
7f14b23 SendReservationEmailActivity dispatched at 7 transition points. embed.FS templates, TR + EN inline. v0.16.0
G.7.6
Recovery sweep
9dbce90 Daily 04:00 UTC cron. CTE-guarded UPDATE catches stale rows whose workflow died. One-shot SendReservationEmailWorkflow per swept row. v0.17.0
03 · Tradeoff

Why Temporal,
not cron.

The Next.js side ran two background crons that scanned the reservations table every 5 minutes and every hour. The Go side runs zero such crons — durable timers fire at the exact second they should.
Was

Next.js · cron-scan

TTL precision
Up to 5 min late — fires on cron tick.
After cluster restart
State reconstructed from DB on next cron tick. Polling resumes.
Pickup reminder
Hourly cron scans for candidates inside a 23–25h window.
Cancel from user
Direct DB write. Email later from next cron sweep.
Email retry on 5xx
Inline. User request blocks or fails.
Operational surface
2 polling crons + their failure modes.
Now

Go · Temporal per-reservation

TTL precision
Exact second — durable timer in workflow history.
After cluster restart
Workflows resume from last completed activity. Idempotent guarded UPDATEs absorb replays.
Pickup reminder
Precise sleep until confirmed_expires_at − 1h.
Cancel from user
SignalWorkflow → activity. Single source of truth.
Email retry on 5xx
Activity retries via Temporal RetryPolicy. No user-facing impact.
Operational surface
0 polling crons. One optional daily recovery sweep.
04 · Surface

Ten endpoints.

Customer-facing GETs redirect anonymous users to /login. Admin routes go through AdminGate, then each handler scopes by the actor's membership store_ids — cross-store attempts return 404, no leak.

Customer · /me

session required
MethodPathBehavior
POST/:locale/listing/:publicId/reserveInsert reservation + kick ReservationWorkflow. Maps 5 sentinel errors to i18n fragments.
GET/:locale/me/reservationsTabbed list. ?tab=active|history with status chips + countdowns.
GET/:locale/me/reservations/:publicIdDetail + reservation_events timeline + cancel button.
POST/:locale/me/reservations/:publicId/cancelSignalWorkflow(cancel). Returns HTMX swap fragment.

Store · /admin/reservations

AdminGate · membership-scoped
MethodPathBehavior
GET/:locale/admin/reservations3 sections: Pending (oldest-first), Confirmed, History.
POST.../:publicId/confirmGuard pending. SignalWorkflow(confirm) → confirmed-to-customer email.
GET.../:publicId/reject-formInline reject modal: 4 reasons + note.
POST.../:publicId/rejectGuard pending. SignalWorkflow(reject, reason, note).
POST.../:publicId/fulfillGuard confirmed. SignalWorkflow(fulfill) → fulfilled email.
POST.../:publicId/no-showGuard confirmed. SignalWorkflow(cancel-store).

Cron · internal

Bearer CRON_SECRET
MethodPathBehavior
POST/api/v1/internal/reservations/recoverDaily 04:00 UTC. Guarded sweep — flips past-TTL rows to expired + emits emails via Temporal. No-op under healthy operation.
05 · Known limits

Deferred,
on purpose.

Six items chosen to be out-of-scope. Each has a fix sketched in the source-truth markdown summary; none block the feature.
01

AdminGate is role-based.

Non-admin store members can't reach /admin/reservations today even though the spec says "any membership can act." Swap AdminGate for a MembershipGate on this route group — handlers themselves are already membership-scoped.

02

No admin store-note endpoint.

Next.js had /admin/reservations/:id/notes for inline store-note edits. Not in the plan; v1.1 candidate.

03

No HTTP integration tests.

Workflow tests cover the state machine via testsuite.WorkflowTestSuite. A round-trip test against a real DB needs the testcontainers env, which has been broken on dev machines for unrelated reasons.

04

Recovery sweeps expiry only.

If a confirmed reservation's workflow dies between hour 0 and 47, the customer never gets the pickup-reminder. The sweep catches expiry but the reminder window is closed. Acceptable for v1.

05

Workflow versioning not added.

Initial deploy is v1. When the state machine first changes shape, use workflow.GetVersion to keep in-flight reservations consistent.

06

No load test of thousands of sleeping workflows.

Temporal handles this cheaply in theory. Confirm under real load before peak season.