Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
The previous commit asserted southern zones round-trip through iCal unchanged but verified it only by reasoning. Close that gap with explicit tests: - A Sydney VTIMEZONE export block: TZID:Australia/Sydney, DAYLIGHT->+1100 (AEDT) / STANDARD->+1000 (AEST), first-Sunday rules (BYMONTH=10/4 BYDAY=1SU), and DAYLIGHT DTSTART:19701004T020000 — confirming the -480 rule time folds the from-offset back to the correct local 02:00 AEST transition. - A southern-zone DTSTART;TZID export -> import round-trip preserving :dtstart. +7 ical tests (now 63). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
334 lines
22 KiB
Markdown
334 lines
22 KiB
Markdown
# 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` → **391/391** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST incl. iCal round-trip)
|
||
|
||
## 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 — Harden southern-hemisphere DST: explicit iCal coverage for the
|
||
previous commit's unverified claim that "southern zones round-trip through
|
||
iCal unchanged". Added a Sydney VTIMEZONE export block (TZID:Australia/Sydney,
|
||
DAYLIGHT→+1100/STANDARD→+1000, first-Sunday rules BYMONTH=10/4 BYDAY=1SU, and
|
||
DAYLIGHT DTSTART:19701004T020000 — proving the −480 rule time folds back to
|
||
local 02:00 AEST) and a southern-zone DTSTART;TZID export→import round-trip.
|
||
+7 tests (ical 63). 391/391 green.
|
||
- 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.
|