Skip to content

Hold System, Redis Architecture & SSE Protocol

⚠️ NOT BUILT. Verified 2026-08-02. No hold store, no booking routes, no scheduling domain. This is the F4 specification. The go/holds.go it references is a transcription that has never compiled or run.

Overview

The hold system prevents double-booking by allowing clients to temporarily reserve ("hold") a timeslot before confirming. Holds are backed by Redis with TTL-based auto-expiry, and state changes are streamed to clients in real time via Server-Sent Events (SSE).

Redis holds are advisory. The database constraint is the guarantee. A hold is a UX device: it stops two patients racing for the same slot in the thirty seconds it takes to fill a contact form. It cannot be the only defence, because Redis can be flushed, evicted, or unavailable, and because staff create appointments through paths that never take a hold at all. The durable guard is the exclusion constraint.


Hold Lifecycle

Client                          Redis                           Other Clients (via SSE)
  │                               │                               │
  │  POST /v1/holds               │                               │
  │  ─────────────────────►       │                               │
  │                               │                               │
  │  1. Check client quota        │                               │
  │     SCARD client:{cid}:holds  │                               │
  │                               │                               │
  │  2. Pick specialist by priority│                               │
  │     (availability.go logic)   │                               │
  │                               │                               │
  │  3. Atomic claim              │                               │
  │     SET hold:{calid}:{slot}:{spid}                             │
  │         value PX 30000 NX ───►│                               │
  │                               │  PUBLISH holds:events:{calid}  │
  │                               │  ─────────────────────────────►│  "hold" event
  │  4. Index by client           │                               │
  │     SADD client:{cid}:holds   │                               │
  │                               │                               │
  │  ◄──── { holdId, specialistId }│                              │
  │                               │                               │
  │  PATCH /v1/holds (heartbeat)  │                               │
  │  ─────────────────────►       │                               │
  │     PEXPIRE hold key + set    │  PUBLISH "heartbeat" event    │
  │  ◄──── { ok: true }          │  ─────────────────────────────►│
  │                               │                               │
  │  POST /v1/calendars/{id}/book                         │
  │  ─────────────────────►       │                               │
  │     DEL hold key              │  PUBLISH "confirm" event      │
  │     SREM from client set      │  ─────────────────────────────►│
  │     INSERT appointment into DB│                               │
  │  ◄──── { appointment }       │                               │
  │                               │                               │
  │       ── OR (no heartbeat) ── │                               │
  │                               │                               │
  │                 TTL expires ──►│                               │
  │                 Key auto-deleted                               │
  │                 (no event published on expiry)                 │

The durable double-booking guard

Ported verbatim from restartix-intakes/core/db/drizzle/migrations/0000_intakes_overlap.sql, retargeted at appointments (F5). Requires CREATE EXTENSION btree_gist, which is not enabled today and must run on DATABASE_DIRECT_URL (P44).

sql
ALTER TABLE appointments
  ADD CONSTRAINT no_overlap_per_specialist_active
  EXCLUDE USING gist (
    specialist_id WITH =,
    tstzrange(started_at, ended_at, '[)') WITH &&
  )
  WHERE (status = '<active>' AND started_at IS NOT NULL AND ended_at IS NOT NULL);

Three properties, each load-bearing (S6):

  1. The constraint is on the SPECIALIST, not the calendar. It therefore holds across calendars — a specialist offering both Physio and Nutrition cannot be booked twice at 10:00, even though the two bookings came through different calendars and different offerings. Keying it on the calendar would be a silent regression.
  2. It is PARTIAL on active status. Cancelled bookings must not block the slot they used to occupy. Leo's predicate is status = 'created'; the platform's equivalent is the active subset of the F5 status enum, and the exact predicate is F5's to fix — but the shape is fixed here: cancelled and no-show rows do not participate.
  3. It permits NULL dates. Non-timeslot bookings are a first-class state, not an error.

The companion CHECK (S7):

sql
CONSTRAINT chk_appointments_time_order CHECK (
  (started_at IS NULL AND ended_at IS NULL)
  OR (started_at IS NOT NULL AND ended_at IS NOT NULL AND ended_at > started_at)
)

Both dates NULL (a no-slot booking) or both set with end > start. Never one of the two.

⚠️ Scope limit. specialists.organization_id NOT NULL means one human working at two clinics has two specialist rows, so this guard is per-org. Leo has the identical limitation for the same reason. Cross-org double-booking is §8.12 and is unresolved.

Staff-created appointments warn, they do not block (A8). The exclusion constraint is the backstop for the booking path. On the staff-created path leo deliberately surfaces specialist overlap as a warning, because appointments get created manually for real reasons. Reconciling "warn" with a hard DB constraint is F5's problem, and it is a real one — flag it there rather than discovering it at insert time.


Redis Key Patterns

⚠️ Every key below is shown in its leo form and must be org-scoped before it ships. Leo's keys carry no organization dimension at all (conflict C13). On this platform each one goes through cache.OrgResource(orgID, …) — the same namespace helper the P45 cache-aside layer uses, so a single grep finds reads and invalidations together. A hold key that omits orgID lets one tenant's booking flow collide with another's.

Hold Storage

Key:    cache.OrgResource(orgID, "hold", calendarId, slotStartDate, specialistId)
        (leo: hold:{scheduleId}:{slotStartDate}:{openingId})
Value:  JSON HoldPayload
TTL:    30 seconds (default), extended by heartbeat
Set:    NX (atomic, fails if already exists)

Example:

Key:    hold:550e8400-...:2025-03-15T09:00:00Z:770a1200-...
Value:  {"holdId":"abc123","clientId":"sess_xyz","calendarId":"550e8400-...","specialistId":"770a1200-...","slotStartDate":"2025-03-15T09:00:00Z","slotEndDate":"2025-03-15T09:30:00Z","holdExpiresAt":"2025-03-15T08:55:30Z"}
TTL:    30000ms

Client Hold Index

Key:    cache.OrgResource(orgID, "client", clientId, "holds")
Type:   SET of holdId strings
TTL:    Same as the hold (re-set on each heartbeat)

Tracks which holds belong to a client. The shared TTL is deliberate (S14) — leo's comment reads "use same TTL as hold to avoid blocking after expiry." An index that outlives its holds locks a client out of rebooking, because the quota check (SCARD) counts index entries, not live holds.

Pub/Sub Channels

Channel:  cache.OrgResource(orgID, "holds:events", calendarId)
Messages: JSON HoldEvent objects

All hold state changes are published here. On the platform this rides internal/core/sse — a shipped Redis pub/sub Hub that already fans out across ECS tasks — rather than a bespoke channel registry.

Timeslot Cache

Key:    cache.OrgResource(orgID, "timeslots", calendarId, specialistId|"pooled")
Value:  JSON timeslot response
TTL:    300 seconds (5 minutes, configurable)

Invalidated explicitly per key when appointments, weekly hours, overrides, or calendar config change. No broad SCAN+DEL (P45) — leo invalidates by wildcard pattern and that does not port.

Booking Cooldown

Key:    cache.OrgResource(orgID, "client_limit", clientId, calendarId)
Value:  JSON { bookedAt, calendarId, cooldownMinutes, appointmentId }
TTL:    cooldownMinutes * 60 seconds

Per-calendar, not global (S12). A patient blocked from rebooking Physio can still book Nutrition. Verified at client-rate-limiting.service.ts:8-12, where the key is client_limit:{clientId}:{scheduleId}.

Booking client identity

The clientId in every key above is a security boundary, and leo's is not one. There it is caller-supplied with the precedence body > query > cookie > generated, never validated, never persisted — and the dashboard regenerates it after every successful booking, which defeats the 24h cooldown entirely.

On the platform:

  • clientId is a server-signed HttpOnly cookie, minted server-side, never accepted from a request body or query string
  • it is persisted to appointments.booking_client_id at booking, so the cooldown survives a cookie clear
  • the cooldown key is (org, calendar, patient_profile_id) for identified patients, or (org, calendar, hashed IP+email) for anonymous public bookings

Hold Creation: Priority-Based Assignment with Retry

When a client requests a hold without specifying specialistId, the scheduling domain selects the best specialist automatically:

1. Get candidate specialists (available at this slot)
   └── For each specialist: check weekly hours, overrides, appointments

2. Filter by priority (highest wins)
   └── If multiple specialists share top priority → step 3

3. Deterministic tiebreak (FNV-1a hash)
   └── Hash seed = fnv1a32(calendarId + ":" + slotStartDate)
   └── Same slot always produces same ordering → consistent, unbiased

4. Attempt SET NX on the selected specialist
   └── Success → publish "hold" event, return
   └── Failure (already held) → add to exclusion set, go to step 2

5. Repeat until success or no candidates remain

Why retry? Between candidate discovery and SET NX, another client may have claimed the same specialist. The retry loop tries the next-best candidate without re-checking availability (it was just checked).

Two ordering rules make this deterministic rather than merely arbitrary:

  • S16 — the tiebreaker is FNV-1a(calendarId:slotStartDate). Determinism is the point: two concurrent callers computing candidates independently, on different API tasks, agree on the ordering without shared state. Replacing it with rand or with insertion order breaks that silently.
  • S17 — "even distribution" is not a separate mode. It is expressed as all priorities set to 0, which collapses the priority filter and hands every slot to the deterministic ring. Verified at leo's reorder route: evenDistribution=true writes priority: 0 for every specialist; false writes descending priorities from length - 1.

See go/assignment.go for the full algorithm — and note it has no tests.


Race Condition Analysis

OperationMechanismSafety
Slot claimSET NX (Redis atomic)Safe — only one client wins
Client quotaSCARD then SET (not atomic)Soft limit — may exceed by 1 under concurrency. Acceptable.
Heartbeat during expiryGET then PEXPIREHold may expire between operations. Client sees false, retries.
Concurrent releaseTwo DEL on same keyFirst succeeds, second returns false. Idempotent.
Expiry cleanupRedis TTL on both hold + client setGuaranteed — no orphaned index entries (S14)
Hold expiry during bookingHold expires between GET and INSERTBooking handler re-verifies the hold before insert. The exclusion constraint is the real guard — a lost hold degrades to a 23P01 unique-violation, not a double booking
Redis unavailable entirelyHolds stop working; booking continues (S13). The DB constraint still prevents overlap. This is the designed degradation, not an outage

Timing and Cooldown Rules

Defaults are not arbitrary; each encodes a clinic behaviour learned in production.

#RuleValue / shape
S10Anti-spam cooldown after a successful bookingslots_cooldown_minutes default 1440 (24h)
S10Minimum notice before a slotmin_lead_time_minutes default 1440 (24h)
S10Rolling horizonhorizon_days default 0 (i.e. explicit-range mode by default)
S13Rate limiting fails OPEN. A Redis error during the cooldown check is logged and the booking proceedsDeliberate: a Redis outage must never stop a clinic taking bookings. Verified client-rate-limiting.service.ts:51-56. The platform's internal/core/ratelimit already fails open on the same reasoning
S15Staff and patient heartbeat budgets differ. Staff need time to find or create a patient mid-booking; patients do notStaff: 20s × 50 ≈ 16.7 min. Patient: 20s × 5 ≈ 100s. Verified in the two INTAKES_CONFIG files (holdHeartbeatLimit 50 vs 5)
Hold TTL / heartbeat interval / holds per client30s / 20s / 1 (REDIS_CONFIG.holds)

Min lead time (S11)

Min-lead-time failure returns a structured error, not a boolean. The UI cannot render a useful message without earliestBookableAt — "book earlier" is not actionable, "booking opens tomorrow at 09:00" is.

json
{
  "message": "Bookings must be made at least 24 hours in advance",
  "minLeadTimeMinutes": 1440,
  "slotStart": "2025-03-15T09:00:00Z",
  "earliestBookableAt": "2025-03-16T08:00:00Z"
}

Leo formats the human string as "2h 30m" / "3 hours" / "45 minutes" (lead-time.service.ts:27-42). On the platform that string is not built in the data layer — the API returns the numbers, next-intl renders the sentence (guardrail G29).

Cooldown lifecycle

  1. Cooldown is set after successful appointment creation, never before. A failed booking must not consume it.
  2. Cancellation clears it, so a patient who cancels can immediately rebook.
  3. Leo clears it by SCANning client_limit:*:{scheduleId} for a matching appointmentId. That does not port — at 20k patients a SCAN-per-cancellation is a production-scale violation. Store the cooldown key on the appointment row (or derive it deterministically from booking_client_id) and delete it directly.

Public projection (S18)

Pooled calendars must not leak which specialist a patient will get before booking. The intended rule is: attach specialist identity and timezone to the public payload only when the calendar has exactly one specialist.

⚠️ Unverified in the source. The port map cites schedules/[id]/details/route.ts, but that route returns getScheduleWithOpenings(id) — every opening, with weekly hours and overrides, unconditionally. No single-opening conditional was found there or in the platform booking page. Treat S18 as a platform requirement, not a ported behaviour: leo appears to leak the full roster on its public endpoint, and the public projection must go through classification.Filter (P39) rather than a hand-built field list.


SSE Stream Protocol

Connection Setup

GET /v1/holds/stream?calendarId={id}&clientId={cid}&leaseMs={ms}

Response Headers:
  Content-Type: text/event-stream; charset=utf-8
  Cache-Control: no-cache, no-transform
  Connection: keep-alive
  X-Accel-Buffering: no

Connection Lifecycle

1. Generate unique connectionId
2. Deduplicate: if client already has a stream, send "replaced" end event
   to old connection and close it
3. Send retry hint: "retry: 5000\n\n"
4. Send init event
5. Load snapshot: listActiveHolds(calendarId)
   └── For each active hold: send "hold" event with isOwnHold flag
   └── For expired holds (TTL <= 0): send "release" event
6. Subscribe to Redis channel: holds:events:{calendarId}
7. Send connected event
8. Start ping interval (every 15s)
9. Start lease timeout (default 15 min, max 1 hour)
10. Forward Redis pub/sub events to client with filtering

Event Filtering

Not all events are forwarded to all clients:

Event TypeForward Rule
holdAlways (affects slot availability)
releaseAlways (affects slot availability)
confirmAlways (affects slot availability)
heartbeatOnly to the hold owner (isOwnHold=true)
System events (init, connected, ping, end)Always

Each forwarded event includes isOwnHold: true|false based on clientId match.

Event Format

All events are SSE-formatted:

data: {"type":"hold","holdId":"abc","clientId":"xyz","calendarId":"...","specialistId":"...","slotStartDate":"...","slotEndDate":"...","holdExpiresAt":"...","isOwnHold":false}\n\n

Connection Termination

ReasonTriggerClient Action
lease-expiredServer-side timeout (15 min default)Reconnect
send-failedWrite to stream failedReconnect
init-failedSnapshot/subscription setup failedReconnect
client-abortClient closed connection
client-cancelStream cancelled
server-shutdownProcess terminationReconnect
replacedSame client opened new streamUse new stream
unknownUnexpected errorReconnect

Per-Client Deduplication

Only one SSE connection per clientId is allowed. If a client opens a new stream while one is active, the old stream receives an end event with reason: "replaced" and is closed.

Memory Management

Node-era concern; does not port. The Intakes handler monitored process heap (warn at heap > 500 MB / RSS > 1 GB) and kept a manual connection registry, because a long-lived Node SSE connection leaks if it isn't explicitly unregistered. In Go, context.Context cancellation on client disconnect makes the registry unnecessary — see SSE in Go. Keep the per-calendar connection count as a metric; drop the heap watchdog.


Rate Limiting

Two distinct limiters, often conflated:

LimiterKeyed onPurposeHome
IP rate limitclient IP, per endpoint groupAbuse control on unauthenticated booking endpointsinternal/core/ratelimitshipped: Redis Store, Policy, Middleware, and IPKey / PrincipalKey / URLParamKey extractors, with tests. Compose it; do not write a new one
Booking cooldown(org, calendar, booking client)Anti-spam: one booking per calendar per 24h (S10/S12)F4 scheduling domain — this document

Cooldown flow

1. Client requests hold → check cooldown (org, calendar, client)
   └── If limited: reject with remaining cooldown + earliest retry time
   └── If Redis errors: LOG AND PROCEED (S13 — fail open)

2. Client completes booking → set cooldown
   └── Redis SET with TTL = cooldown_minutes * 60, AFTER the appointment row commits

3. Appointment cancelled → clear cooldown
   └── Direct DEL on the key derived from the appointment's booking_client_id
   └── Client can book again immediately

Design decisions

  • Cooldown is set after successful appointment creation, not before. Failed bookings do not consume it.
  • The check is fail-open (S13). Deliberate, and it is the reason the durable double-booking guard has to live in the database.
  • Clearing does not SCAN. Leo scans client_limit:*:{scheduleId} looking for a matching appointmentId. At 20k patients that is a Production-Scale violation on a hot path; derive the key from appointments.booking_client_id and DEL it.
  • Default cooldown: 1440 minutes (24h), configurable per calendar.

Timeslot Caching

Cache Key

cache.OrgResource(orgID, "timeslots", calendarId, specialistId|"pooled")

TTL

300 seconds (5 minutes), configurable.

Invalidation

Explicit per key, never SCAN+DEL (P45). The write path knows exactly which calendars it touched; broad invalidation belongs at the Next.js tag layer (P42), not in Redis.

EventKeys invalidated
Appointment created / cancelled / rescheduledthe affected calendar's pooled key + the assigned specialist's key
Weekly hours changedevery calendar the specialist is rostered on
Override created / updated / deletedthe affected calendar(s) — all of them if calendar_id is NULL
Calendar config updatedthat calendar's keys
Specialist deactivated (scheduling_active = false) or soft-deletedevery calendar the specialist is rostered on

Go Implementation Reference

⚠️ Reference transcription only — no module, no tests, never executed. Read each file's header before use; the full status table is in index.md.

ConcernGo FileStatus
Hold CRUD, heartbeat, release, confirm, cooldowngo/holds.goneeds go-redis/v9; keys not org-scoped
Priority-based specialist assignmentgo/assignment.gogo vet clean in a scratch module
Availability engine (slot calculation)go/availability.gogo vet clean in a scratch module
Type definitions, Redis key buildersgo/types.gogo vet clean in a scratch module
Cloudflare-aware client IPgo/ip.gosuperseded by internal/shared/clientip — delete on port
IP rate-limit middlewarego/ratelimit_ip.godoes not compile (undefined httputil) and superseded by internal/core/ratelimit — delete on port

SSE in Go

No SSE handler is included in the go/ files, and none should be written from scratch. internal/core/sse already ships and is in production use by the TV/kiosk companion. It provides:

PieceWhat it does
sse.HubRedis pub/sub fan-out across ECS tasks — Subscribe(ctx, channel) returns a channel plus an unsubscribe func; Publish(ctx, channel, event) returns the subscriber count
SetRetained / GetRetainedRetained last-event-per-type with TTL, which is how a reconnecting client gets a snapshot without a bespoke listActiveHolds replay path
sse.WriterNewWriter(rw) handles headers, flushing, and Keepalive() ticks
NewJSONEventEvent marshalling

The hold stream is therefore a channel-naming and event-filtering exercise, not an infrastructure build. Add a HoldsChannel(orgID, calendarID) helper alongside the existing PairChannel / RunChannel, and reuse everything else.

What Go gives for free that the Node implementation hand-rolled:

  • context.Context cancellation on client disconnect — no manual connection registry, no heap watchdog
  • http.Flusher in net/http — no framework
  • one goroutine per connection
  • go-redis pub/sub tied to context cancellation

Do not poll. setInterval / router.refresh() polling of live availability is forbidden in production read paths; the SSE hub is the mechanism.