# events-on-sx: Calendar, ticketing & notification delivery on Datalog > **DRAFT outline.** The events vertical + the shared notification-delivery edge. > Depends on `persist-on-sx` (bookings ledger) and `flow-on-sx` (reminders, retrying > delivery). Pairs with `commerce-on-sx` for paid tickets. rose-ash's `events` domain is calendar + ticketing: recurring events, availability, capacity, bookings. Scheduling is constraint reasoning — "is this slot free given recurrence, capacity, and the attendee's other bookings?" — which is rule evaluation over facts. Datalog expresses availability, recurrence expansion, and capacity as rules; a booking is a transaction; reminders and digests are durable `flow`s. Notification *delivery* (email/push) — needed here and by `feed/notify` — is folded in as an injected transport, extractable later. End-state: a Datalog-on-SX events layer with recurrence expansion, availability + capacity rules, transactional booking, and a flow-driven notification dispatcher (reminders, digests, retries) over an injected transport. ## Status (rolling) `bash lib/events/conformance.sh` → **311/311** (Phases 1-4 + 10 ext: …timezones+DST, e2e delivery pipeline, cross-event conflict-checked booking) ## Ground rules - **Scope:** only `lib/events/**` and `plans/events-on-sx.md`. May **import** from `lib/datalog/`, and (once they exist) `lib/persist/` + `lib/flow/`. Do not edit substrates. - **Architecture:** events/availability/capacity are Datalog facts + rules; recurrence expands to occurrence facts within a window; a booking checks rules then appends a `persist` event (idempotent, capacity-safe). Notifications are flows that suspend on transport IO and retry on failure. - **Determinism:** recurrence expansion + availability must be reproducible for a fixed window + ruleset; capacity checks must be race-safe (no overbooking). - **Commits:** one feature per commit. Progress log + tick boxes. ## Architecture sketch ``` Event + booking Result event(id,start,rrule,capacity) {:booked | :full | :conflict} + reminders │ ▲ ▼ │ lib/events/calendar.sx lib/events/availability.sx — event facts, recurrence (RRULE) — free/busy + capacity rules (Datalog) — expand occurrences in window │ │ ▲ ▼ │ lib/events/booking.sx lib/events/notify.sx (flow) — transactional, capacity-safe — reminders / digests, retry on fail — bookings → persist ledger — injected transport (email/push) │ │ ▼ ▼ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──────┘ ``` ## Phase 1 — Calendar + recurrence - [x] `calendar.sx` — event facts, RRULE expansion in a window (DAILY/WEEKLY) - [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday) - [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog) - [x] `availability.sx` — next-free slot search (same rules, different bindings) - [x] `api.sx` — public entry points (schedule/agenda/book/free/next-free/conflicts) - [x] tests + scoreboard + conformance.sh (73/73) ## Phase 2 — Ticketing + booking - [x] capacity rules; transactional booking → `persist` (no overbooking) - [x] wire `booking.sx` into `api.sx` (persist-backed `ev/book-occ!` + derived availability) - [x] cancellation (tombstone events) + seat release - [x] provisional holds (hold/confirm/release) — reserve a seat during pending payment - [x] paid tickets compose with `commerce` order flow (contract module over holds) - [x] tests: capacity edge, double-book guard, conflict detection ## Phase 3 — Notification delivery (flow) - [x] `notify.sx` — reminder/digest flows over injected transport - [x] retry/backoff on transport failure (flow suspend/resume) - [x] tests: delivery success, retry path, idempotent re-send - [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster) - [x] end-to-end pipeline: derive (reminders/booking/reschedule) → deliver via the notify flow (`ev/deliver-messages`, SX→Scheme bridge) - [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a `delivery-on-sx` once a second consumer is real. **Delivery core (request→dispatch→resume, idempotent, bounded retry) is the extraction seam.** ## Phase 4 — Federation - [x] cross-instance events (peer calendar) — trust-gated stub - [x] tests: federated agenda merge - [x] federated availability/free-busy across trusted peers - [x] injected transport (`ev/federated-agenda-via` + fetch) — fed-sx-ready, graceful degradation ## Progress log - 2026-06-07 — Cross-event conflict-checked booking (extension). Capacity is per-event, but `ev/book-checked!` also prevents an attendee double-booking THEMSELVES across different events: it consults the actor's persist-derived availability (ev/free-p?) for the occurrence's window and returns :time-conflict on overlap, else the normal ev/book-occ! result. Re-booking the same occurrence is idempotent (:already, not a conflict); other actors are unaffected. `ev/would-time-conflict?` predicate. +8 tests, 311/311 green. - 2026-06-07 — End-to-end delivery pipeline (closes the derivation↔delivery gap). `ev/deliver-messages` bridges SX notification messages to the Scheme notify flow: each (id recipient body) is `serialize`d to s-expression text, spliced as quoted data into the digest-flow program, delivered over an injected transport-src, and results unboxed ({:scm-string}→str). New integration suite drives all three derivations through delivery: reminders → delivered (ids = idempotency keys), transient-fail transport → failed, waitlist-promotion notification → delivered, reschedule notice → delivered, empty batch → empty (guarded: an empty digest completes without suspending). +8 tests, 303/303 green. - 2026-06-07 — Timezone + DST support (user request). `timezone.sx`: a tz maps wall-clock LOCAL ↔ absolute UTC (offset = local-utc). :fixed (constant) and :dst (std/dst offsets + two UTC transition rules, e.g. EU last-Sun-Mar/Oct 01:00 UTC) zones, no IANA DB — transitions computed via calendar helpers (ev-resolve-nth-weekday). `ev-event-tz` authors an event in local time + a tz; `ev-expand` dispatches: tz events expand in LOCAL time (recurrence + EXDATE/ RDATE + overrides all wall-clock), then each occurrence converts to UTC, so a "09:00 weekly" meeting stays 09:00 across a DST change (its UTC instant shifts). Predefined ev-tz-utc/london/paris. local->utc inverts with a one-step refinement. Plain events unaffected (ev-expand-naive). +17 tests, 295/295 green. - 2026-06-07 — Injected federation transport (last plan item). `fetch` abstracts how a peer's agenda arrives: (fetch peer-id ws we) -> {:status :ok :occurrences} | {:status :error}. `ev/federated-agenda-via` merges local + each trusted peer fetched via the transport, tagged with :origin; an unreachable peer is skipped (graceful degradation), never breaking the agenda. `ev/peer-fetch` is the in-process adapter (runs the existing store model through the same interface); a real fed-sx/signed-fetch transport drops in unchanged. `ev/federation-status` reports per-peer reachability. +6 tests, 278/278 green. All plan checkboxes (incl. extensions) now ticked. - 2026-06-07 — Reschedule notifications (extension). When an event carries per-occurrence overrides, `ev/reschedule-notifications` reads the roster at each overridden occurrence's ORIGINAL occ-key and produces a reschedule message per booked attendee (old-start, new-start, new-duration). Idempotency key = original-key/reschedule/new-start. `ev/reschedule-notify->msg` for the notify wire shape. Combines overrides (calendar) + rosters (booking) + the message-derivation pattern. +7 tests, 272/272 green. - 2026-06-07 — Booking lifecycle notifications (extension). `booking-notify.sx` walks the booking stream into ordered notifications classified by kind: :booked / :promoted / :held / :confirmed / :released / :cancelled / :waitlisted. Promotion is detected by folding the waitlist as we walk (a :booking for a currently-waitlisted actor is a promotion, not a fresh booking). id = occ-key/seq (stable stream seq → idempotent re-derivation, no double-ping). `ev/booking-notifications`, `ev/notify-of-kind`, `ev/booking-notify->msg` (notify wire shape). Connects ticketing to the delivery layer. +11 tests, 265/265 green. - 2026-06-07 — Per-occurrence overrides / reschedule (RFC 5545 RECURRENCE-ID). `ev-with-override event orig-start new-start new-duration` adds an :overrides entry keyed by the occurrence's original start. `ev-expand` applies overrides after EXDATE/RDATE: a targeted instance is re-timed/re-sized and the agenda re-sorted; an instance moved out of the window is dropped (slot vacated); override of a non-occurring start is a no-op. Used `assoc` for immutable event update. +6 tests, 254/254 green. - 2026-06-07 — RRULE exceptions EXDATE/RDATE (extension). `ev-event-full` carries :exdate/:rdate (epoch-minute starts). Raw expansion renamed `ev-expand-base`; `ev-expand` now applies exceptions: RDATE adds explicit in-window occurrences, EXDATE removes matching starts, duplicates de-duped, EXDATE wins over RDATE and the rrule (RFC 5545). RDATE-only events (no rrule) supported. Plain `ev-event` (no exception keys) unaffected. +8 tests, 248/248 green. - 2026-06-07 — Waitlist + auto-promotion (extension). When an occurrence is full, `ev/waitlist!` queues actors FIFO (:waitlist/:unwaitlist events on the same stream; waiting fold is independent of the seat fold since taking a seat removes from the queue). `ev/waitlist` (queue), `ev/waitlist-position`, `ev/leave-waitlist!`. `ev/cancel-promote!` cancels a seat and auto-promotes the head of the queue to a confirmed booking when capacity opens. Idempotent (:already / :already-waiting). +21 tests, 240/240 green. - 2026-06-07 — Federated free/busy (extension). Peers publish BUSY intervals per actor (iCal free/busy model — privacy-preserving, not event details). `ev/peer-with-busy`, `ev/peer-busy`; `ev/federated-busy` unions local availability-db busy + trusted peers' published busy (sorted); `ev/federated-free?` answers "is X free in [qs,qe)?" across instances, half-open, trust-gated (untrusted peers' busy ignored; revocation immediate). +10 tests, 219/219 green. - 2026-06-07 — **Phase 4: federation (trust-gated stub).** `federation.sx`: a peer publishes a schedule (events store); `ev/federated-agenda` merges the local agenda (origin :local) with every TRUSTED peer's agenda, sorted by start, each occurrence tagged with :origin provenance. Trust is a peer-id set re-checked per merge (revocation is immediate); untrusted peers contribute nothing. `ev/peer`, `ev/trusts?`, `ev/trusted-peers`, `ev/peer-agenda` (expands the peer's recurrence in-window), `ev/from-origin` (filter by source). Real transport slots behind `ev/peer-agenda` unchanged. +13 tests, **209/209 green — all four plan phases implemented.** - 2026-06-07 — Reminders + digests from the agenda. `reminders.sx` bridges calendar + durable rosters to notify: `ev/occurrence-reminders` (one per booked attendee, fires `lead` before start, idempotency key occ-key/recipient/lead), `ev/agenda-reminders` (window-wide, sorted by fire-at), `ev/due-reminders` (fire-at ≤ now — the scheduler query), `ev/reminder->msg` (projects to notify's (id recipient body) shape), `ev/agenda-digest` + `ev/agenda-for-p` (an actor's upcoming booked occurrences). +14 tests, 196/196 green. - 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`: reminders + digests as durable `flow`s over an INJECTED transport (the host `dispatch`). A flow `request`s delivery (suspend), the host sends and resumes with the outcome; flow's replay log means a completed send is never re-run on recovery. At-least-once + idempotent: messages carry an id; the transport dedups (re-send is a no-op that still reports ok) and replay logs each outcome. Retry rides suspend/resume — each attempt uses a DISTINCT tag `(deliver )` so replay stays correct; dispatch returns (ok) / (retry reason), bounded by maxn → (failed id reason). Digest delivers a batch with independent per-message outcomes. Authored as Scheme flow source run via `ev/notify-run` (scheme + flow substrate preloaded). +7 tests, 182/182 green. Delivery core is the `delivery-on-sx` extraction seam for feed/notify. - 2026-06-07 — **Phase 2 complete: paid-ticket contract.** `ticket.sx` defines the two wire messages between events and commerce — `checkout-request` (events→commerce) and `payment-result` (commerce→events, :paid/:failed/ :expired) — so commerce imports the contract, not vice versa. Orchestration over holds: `ev/request-ticket!` places a capacity-safe hold + emits a checkout-request; `ev/settle-payment!` confirms on :paid, releases on failure/expiry. Idempotent (redelivered :paid stays confirmed, redelivered release is :noop); a late :paid for a vanished hold → :paid-but-no-hold (refund signal), no phantom seat. occ-key+actor locate the hold so no side table. +31 tests, 175/175 green. Phase 3 (notification flows) is next. - 2026-06-07 — Provisional holds (paid-ticket foundation). Booking stream now carries :booking/:hold/:confirm/:release/:cancel; the fold tracks per-actor seat STATE (:held / :confirmed). A held seat counts toward capacity, so a pending payment cannot be oversold. `ev/hold!` (capacity-safe, retrying), `ev/confirm!` (held→confirmed), `ev/release!` (frees a held seat only), `ev/seat-state`. Seat-acquiring writes (:booking/:hold) go through append-expect; seat-freeing writes (:cancel/:release) and :confirm append directly (never oversell). Holds race test mirrors the booking race. +24 tests, 144/144 green. Next: ticket.sx contract module over holds. - 2026-06-07 — Wired `booking.sx` into `api.sx`: durable persist-backed booking path alongside the in-memory one. `ev/book-occ!`, `ev/cancel-occ!`, `ev/roster-occ`, `ev/seats-left-occ` (capacity from the scheduled event); `ev/free-p?`, `ev/next-free-p`, `ev/conflicts-p` derive availability by replaying persist booking streams for in-window occurrences. Capacity-safe + cancellable bookings now flow through the public API. Reordered conformance preloads (persist + booking before events/api). +10 tests, 120/120 green. - 2026-06-07 — Cancellation + seat release. Booking stream now carries :booking / :cancel events; the live roster is the FOLDED replay (book adds, cancel removes) so capacity reopens when a seat is freed. `ev/cancel!` (retrying, append-expect), `ev/cancel-with-observed`. Edge cases: cancelling an unbooked actor → :not-booked (no-op), double cancel → :not-booked, cancelled actor may re-book. Capacity count is the folded roster size, not the physical event count. +13 tests, 110/110 green. - 2026-06-07 — **Phase 2 start: capacity-safe booking.** `booking.sx`: one append-only persist stream per occurrence; roster = stream replayed. Booking decisions made against an OBSERVED (roster, last-seq) snapshot, committed via `persist/append-expect` — append only if last-seq unchanged, else a conflict value the booker retries. This makes capacity-check + append atomic at the persist boundary: no overbooking, no lock. `ev/book!` (retrying), `ev/book-with-observed`, `ev/roster`, `ev/seats-left`. Idempotent per actor (:already). Explicit last-seat race test: two bookers on the same snapshot → one :booked, one :conflict, roster never exceeds capacity; loser retry → :full (or next seat when room remains). 24 tests, 97/97 green. - 2026-06-07 — **Phase 1 complete.** `api.sx`: immutable `store` ({:events :bookings}) facade over calendar + availability. `ev/schedule`, `ev/book`, `ev/agenda`, `ev/agenda-for`, `ev/free?`, `ev/next-free`, `ev/conflicts`. Availability queries auto-widen the expansion window back by the longest event so any overlapping occurrence is captured. 14 tests, 73/73 green. Phase 2 (transactional booking on persist) is next — `ev/book` becomes capacity-safe via a persist append at that point. - 2026-06-07 — `next-free` slot search: earliest start ≥ after where [s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after` plus each busy-interval end (interval-packing); each probe reuses the `busy_in` Datalog rule via `ev-free?`. Finds gaps between bookings, skips too-short gaps, half-open at edges. +6 tests, 59/59. - 2026-06-07 — `availability.sx`: free/busy + conflict detection as forward- chained Datalog over `occurrence`/`booking` EDB. Rules `busy(A,S,E)`, `conflict(A,O1,O2)` (canonical `O1day-number conversion, DAILY/WEEKLY RRULE expansion in a bounded (start,end) window with INTERVAL, COUNT (window- independent), UNTIL, BYDAY (weekly). `ev-expand-all` merges + sorts. Wired conformance harness (conf + thin wrapper reusing `lib/guest/conformance.sh`), scoreboard. 24/24 green. MONTHLY deferred to next commit. ## Blockers - None. Substrates present: `lib/datalog` (276/276), `lib/persist`, `lib/flow` all exist — Phase 2/3 unblocked when reached.