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.goit 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).
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):
- 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.
- 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. - It permits NULL dates. Non-timeslot bookings are a first-class state, not an error.
The companion CHECK (S7):
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 NULLmeans 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 omitsorgIDlets 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: 30000msClient 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 objectsAll 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 secondsPer-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:
clientIdis a server-signed HttpOnly cookie, minted server-side, never accepted from a request body or query string- it is persisted to
appointments.booking_client_idat 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 remainWhy 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 withrandor 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=truewritespriority: 0for every specialist;falsewrites descending priorities fromlength - 1.
See go/assignment.go for the full algorithm — and note it has no tests.
Race Condition Analysis
| Operation | Mechanism | Safety |
|---|---|---|
| Slot claim | SET NX (Redis atomic) | Safe — only one client wins |
| Client quota | SCARD then SET (not atomic) | Soft limit — may exceed by 1 under concurrency. Acceptable. |
| Heartbeat during expiry | GET then PEXPIRE | Hold may expire between operations. Client sees false, retries. |
| Concurrent release | Two DEL on same key | First succeeds, second returns false. Idempotent. |
| Expiry cleanup | Redis TTL on both hold + client set | Guaranteed — no orphaned index entries (S14) |
| Hold expiry during booking | Hold expires between GET and INSERT | Booking 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 entirely | — | Holds 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.
| # | Rule | Value / shape |
|---|---|---|
| S10 | Anti-spam cooldown after a successful booking | slots_cooldown_minutes default 1440 (24h) |
| S10 | Minimum notice before a slot | min_lead_time_minutes default 1440 (24h) |
| S10 | Rolling horizon | horizon_days default 0 (i.e. explicit-range mode by default) |
| S13 | Rate limiting fails OPEN. A Redis error during the cooldown check is logged and the booking proceeds | Deliberate: 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 |
| S15 | Staff and patient heartbeat budgets differ. Staff need time to find or create a patient mid-booking; patients do not | Staff: 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 client | 30s / 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.
{
"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
- Cooldown is set after successful appointment creation, never before. A failed booking must not consume it.
- Cancellation clears it, so a patient who cancels can immediately rebook.
- Leo clears it by
SCANningclient_limit:*:{scheduleId}for a matchingappointmentId. 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 frombooking_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 returnsgetScheduleWithOpenings(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 throughclassification.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: noConnection 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 filteringEvent Filtering
Not all events are forwarded to all clients:
| Event Type | Forward Rule |
|---|---|
hold | Always (affects slot availability) |
release | Always (affects slot availability) |
confirm | Always (affects slot availability) |
heartbeat | Only 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\nConnection Termination
| Reason | Trigger | Client Action |
|---|---|---|
lease-expired | Server-side timeout (15 min default) | Reconnect |
send-failed | Write to stream failed | Reconnect |
init-failed | Snapshot/subscription setup failed | Reconnect |
client-abort | Client closed connection | — |
client-cancel | Stream cancelled | — |
server-shutdown | Process termination | Reconnect |
replaced | Same client opened new stream | Use new stream |
unknown | Unexpected error | Reconnect |
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.Contextcancellation 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:
| Limiter | Keyed on | Purpose | Home |
|---|---|---|---|
| IP rate limit | client IP, per endpoint group | Abuse control on unauthenticated booking endpoints | internal/core/ratelimit — shipped: 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 immediatelyDesign 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 matchingappointmentId. At 20k patients that is a Production-Scale violation on a hot path; derive the key fromappointments.booking_client_idandDELit. - 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.
| Event | Keys invalidated |
|---|---|
| Appointment created / cancelled / rescheduled | the affected calendar's pooled key + the assigned specialist's key |
| Weekly hours changed | every calendar the specialist is rostered on |
| Override created / updated / deleted | the affected calendar(s) — all of them if calendar_id is NULL |
| Calendar config updated | that calendar's keys |
Specialist deactivated (scheduling_active = false) or soft-deleted | every 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.
| Concern | Go File | Status |
|---|---|---|
| Hold CRUD, heartbeat, release, confirm, cooldown | go/holds.go | needs go-redis/v9; keys not org-scoped |
| Priority-based specialist assignment | go/assignment.go | go vet clean in a scratch module |
| Availability engine (slot calculation) | go/availability.go | go vet clean in a scratch module |
| Type definitions, Redis key builders | go/types.go | go vet clean in a scratch module |
| Cloudflare-aware client IP | go/ip.go | superseded by internal/shared/clientip — delete on port |
| IP rate-limit middleware | go/ratelimit_ip.go | does 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:
| Piece | What it does |
|---|---|
sse.Hub | Redis 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 / GetRetained | Retained last-event-per-type with TTL, which is how a reconnecting client gets a snapshot without a bespoke listActiveHolds replay path |
sse.Writer | NewWriter(rw) handles headers, flushing, and Keepalive() ticks |
NewJSONEvent | Event 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.Contextcancellation on client disconnect — no manual connection registry, no heap watchdoghttp.Flusherinnet/http— no framework- one goroutine per connection
go-redispub/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.