Files
rose-ash/plans/events-on-sx.md
giles b308effb9f
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
events: per-occurrence overrides / reschedule (RECURRENCE-ID) + 6 tests
ev-with-override re-times/re-sizes a single instance of a series (keyed by
original start). ev-expand applies overrides after EXDATE/RDATE: agenda
re-sorts, instance moved out of window is dropped (slot vacated), no-op for a
non-occurring start. assoc for immutable event update. 254/254 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:52:02 +00:00

14 KiB

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 flows. 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.sh254/254 (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides)

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

  • calendar.sx — event facts, RRULE expansion in a window (DAILY/WEEKLY)
  • calendar.sx — MONTHLY (bymonthday + nth-weekday byday)
  • availability.sx — free/busy rules (busy/conflict/busy_in on Datalog)
  • availability.sx — next-free slot search (same rules, different bindings)
  • api.sx — public entry points (schedule/agenda/book/free/next-free/conflicts)
  • tests + scoreboard + conformance.sh (73/73)

Phase 2 — Ticketing + booking

  • capacity rules; transactional booking → persist (no overbooking)
  • wire booking.sx into api.sx (persist-backed ev/book-occ! + derived availability)
  • cancellation (tombstone events) + seat release
  • provisional holds (hold/confirm/release) — reserve a seat during pending payment
  • paid tickets compose with commerce order flow (contract module over holds)
  • tests: capacity edge, double-book guard, conflict detection

Phase 3 — Notification delivery (flow)

  • notify.sx — reminder/digest flows over injected transport
  • retry/backoff on transport failure (flow suspend/resume)
  • tests: delivery success, retry path, idempotent re-send
  • wire reminders to occurrences (reminders.sx — derive from agenda + roster)
  • 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

  • cross-instance events (peer calendar) — trust-gated stub
  • tests: federated agenda merge
  • federated availability/free-busy across trusted peers
  • real transport behind ev/peer-agenda (fed-sx / signed fetch)

Progress log

  • 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 flows over an INJECTED transport (the host dispatch). A flow requests 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 <id> <n>) 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 O1<O2, half-open overlap S1<E2 ∧ S2<E1), busy_in(A,QS,QE) for window queries. API: ev-busy, ev-conflicts, ev-has-conflict?, ev-free? (transient qwindow assert/retract). Integrates with calendar expansion (book expanded occurrences). 16 tests, 53/53 green.
  • 2026-06-06 — MONTHLY recurrence. ev-days-in-month, ev-add-months, BYMONTHDAY (incl. negative = from month end), ordinal BYDAY ({:ord N :wd W}, ord<0 = nth-from-last), default day-of-month (skips months too short, e.g. day-31 monthly skips Feb/Apr). Refactored weekly+monthly onto a shared ev-emit-occs per-period emitter. 37/37 green (+13).
  • 2026-06-06 — Phase 1 scaffold + calendar recurrence. calendar.sx: integer epoch-minute datetimes, Hinnant civil<->day-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.