Files
rose-ash/plans/events-on-sx.md
giles 78b45a331e
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
events: southern-hemisphere DST (+8) — 384/384
The :dst zone model assumed northern ordering (dst-start < dst-end, DST =
[start, end)). Southern zones — DST begins ~Oct and ends ~Apr — have
dst-start > dst-end, so the old (>= start AND < end) test was never true and
ev-tz-offset returned the standard offset year-round.

Fix: detect the ordering. start < end → DST is [start, end); start > end →
DST wraps the calendar-year boundary, active when (utc >= start OR utc < end).

Add predefined ev-tz-sydney (AEST +600 / AEDT +660; transitions 02:00 AEST
first-Sun-Oct and 03:00 AEDT first-Sun-Apr, both 16:00 UTC the preceding
Saturday → rule time -480). VTIMEZONE export is already rule-agnostic, so
southern zones round-trip through iCal unchanged (the -480 folds the
from-offset back to the correct local 02:00/03:00 DTSTART).

+8 timezone tests (now 25): summer/winter offsets, both transition dates,
local->utc in both seasons, and a daily expansion crossing the autumn DST-end
that shifts in UTC (1320,1320,1380,1380,1380) while staying 09:00 local.

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

327 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**384/384** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST)
## 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-10 — Southern-hemisphere DST. The `:dst` zone model assumed northern
ordering (dst-start < dst-end, DST = [start, end)); southern zones (DST begins
~Oct, ends ~Apr) have dst-start > dst-end and so silently never entered DST —
`ev-tz-offset` returned std year-round. Fixed by detecting the ordering: when
start < end DST is the interval [start, end); when start > end DST wraps the
year boundary (active when `utc ≥ start OR utc < end`). Added predefined
`ev-tz-sydney` (AEST +600 / AEDT +660; transitions 02:00 AEST first-Sun-Oct
and 03:00 AEDT first-Sun-Apr, both 16:00 UTC the prior Saturday → rule time
480). VTIMEZONE export already rule-agnostic, so southern zones round-trip
too (the 480 folds the from-offset back to the correct local 02:00/03:00).
+8 tests (timezone 25): summer/winter offsets, both transition dates,
local→utc both seasons, and a daily expansion crossing the autumn DST-end that
shifts in UTC (1320·1320·1380·1380·1380) yet stays 09:00 local. 384/384 green.
- 2026-06-07 — VTIMEZONE iCal export (supersedes the UTC-Z tz fix — full DST
fidelity). A tz event now exports DTSTART;TZID=<name>:<local> (+ EXDATE/RDATE
in the same TZID-local form; UNTIL stays UTC per RFC), and the VCALENDAR emits
a VTIMEZONE per distinct zone with DAYLIGHT/STANDARD sub-components generated
from the zone's transition rules (offsets + FREQ=YEARLY;BYMONTH;BYDAY) — the
London/Paris blocks match real-world definitions exactly. So a client recurs
the event at a fixed WALL-CLOCK time, DST-correct (the prior caveat is gone).
`ev-ical-vtimezone`, `ev-ical-offset`, distinct-zone collection; importer now
tolerates the ;TZID= parameter. +16 tests (ical 56), 376/376 green.
- 2026-06-07 — Fix: timezone-aware iCal export. Bug — tz events store wall-clock
LOCAL times, but export stamped them with a `Z` (UTC) suffix, so a London
18:00 event falsely read as 18:00 UTC. `ev-ical-conv` now converts a tz
event's DTSTART / UNTIL / EXDATE / RDATE local→UTC before formatting (London
summer 18:00 → 170000Z; Paris → 160000Z); non-tz events unchanged. Documented
caveat: a UTC RRULE drifts from a wall-clock-stable tz recurrence across a DST
boundary — full fidelity needs VTIMEZONE (deferred). +6 tests, 366/366 green.
- 2026-06-07 — iCalendar import / round-trip (extension). `ical.sx` now parses
VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`,
`ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,
UNTIL/COUNT/INTERVAL) and EXDATE/RDATE. Round-trip is occurrence-exact —
export→import expands to the identical occurrence set (tested across one-off /
daily-count / weekly+exdate+rdate / monthly-ordinal / bymonthday). Completes
bidirectional interop. +19 tests, 360/360 green.
- 2026-06-07 — Whole-series booking (extension). `ev/book-series!` /
`ev/cancel-series!` apply a booking/cancel to every occurrence of one event
in a window (e.g. RSVP the whole weekly class), returning per-occurrence
(occ-key status) results; capacity is still enforced per occurrence (some
:booked, some :full). Idempotent re-book (all :already). `ev/series-count`
(tally a status), `ev/series-booked` (which occurrences the actor holds).
+9 tests, 341/341 green. This was the last flagged feature — surface saturated.
- 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes
events to VEVENT / VCALENDAR text for import by standard clients. UTC
basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE
model (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals "2TU"/"-1FR"/
BYMONTHDAY) plus EXDATE/RDATE. Line-oriented: `ev/event->ical-lines` /
`ev/events->ical-lines` return content lines; `ev/ical-render` joins with
CRLF (wire format). +21 tests, 332/332 green.
- 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 <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.