Compare commits

..

104 Commits

Author SHA1 Message Date
7d1d732623 events: sync scoreboard.md to 391/391
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Companion to b2578139 — the markdown scoreboard render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:05:53 +00:00
b257813926 events: sync scoreboard to 391/391
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Has started running
Scoreboard artifact for the southern-hemisphere DST commits (78b45a33,
6716af69) — timezone 17->25, ical 56->63, total 376->391. Was left out of
those commits; committing now to keep the tracked scoreboard in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:05:35 +00:00
6716af69dc events: iCal coverage for southern-hemisphere VTIMEZONE (+7) — 391/391
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>
2026-06-10 22:04:04 +00:00
78b45a331e events: southern-hemisphere DST (+8) — 384/384
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
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
826d926740 events: VTIMEZONE iCal export — full DST-correct tz recurrence + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
A tz event now exports DTSTART;TZID=<name>:<local> (EXDATE/RDATE likewise;
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) — London/Paris blocks match real-world
definitions. Clients recur at fixed wall-clock time, DST-correct (prior caveat
gone). Importer tolerates ;TZID= params. 376/376 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:03:07 +00:00
34c9b211ac events: fix timezone-aware iCal export (local->UTC stamps) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
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. Caveat: UTC RRULE drifts from wall-clock-stable tz recurrence across
a DST boundary (VTIMEZONE deferred). 366/366 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:34:20 +00:00
3913bc368c events: iCalendar import + occurrence-exact round-trip + 19 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
ical.sx parses VEVENT/VCALENDAR text back into events (ev/ical-lines->event,
ev/parse-vcalendar): DTSTART/DURATION/RRULE (ordinal BYDAY, BYMONTHDAY, UNTIL/
COUNT/INTERVAL) + EXDATE/RDATE. Round-trip is occurrence-exact — export->import
expands to the identical occurrence set. Completes bidirectional interop.
360/360 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:28:26 +00:00
94aaf0e433 events: whole-series booking + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 59s
ev/book-series! / ev/cancel-series! apply a booking/cancel to every occurrence
of one event in a window (RSVP the whole weekly class), returning per-
occurrence (occ-key status) results; capacity still enforced per occurrence
(some :booked, some :full), idempotent re-book (:already). ev/series-count,
ev/series-booked. 341/341 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:20:27 +00:00
ddc6635fa8 events: iCalendar (RFC 5545) export + 21 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
ical.sx serializes events to VEVENT/VCALENDAR text for import by standard
clients: UTC basic-format stamps, DURATION (PT#H#M), full RRULE
(FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals 2TU/-1FR/BYMONTHDAY)
plus EXDATE/RDATE. Line-oriented (ev/event->ical-lines / ev/events->ical-lines)
with ev/ical-render joining CRLF for the wire format. 332/332 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:41:08 +00:00
02b721854e events: cross-event conflict-checked booking + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
ev/book-checked! prevents an attendee double-booking themselves across
different events: consults their persist-derived availability (ev/free-p?) for
the occurrence window, returns :time-conflict on overlap else the normal
ev/book-occ! result. Re-booking the same occurrence stays idempotent
(:already); other actors unaffected. ev/would-time-conflict? predicate.
311/311 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:59:37 +00:00
1446eaaa47 events: end-to-end delivery pipeline (derivation -> notify flow) + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
ev/deliver-messages bridges SX notification messages to the Scheme notify
flow: each (id recipient body) is serialized to s-expr text, spliced as quoted
data into the digest-flow program, delivered over an injected transport, and
results unboxed. Integration suite drives all three derivations (reminders /
booking-notify / reschedule) through delivery end to end; empty batch guarded
(empty digest completes without suspending). 303/303 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:52:00 +00:00
bf7bd38010 events: timezone + DST support + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
timezone.sx: wall-clock LOCAL <-> absolute UTC. :fixed + :dst zones (std/dst
offsets + UTC transition rules, EU-style, no IANA DB) computed via calendar
helpers. ev-event-tz authors in local time; ev-expand expands tz events in
LOCAL time then converts each occurrence to UTC, so a 09:00 weekly meeting
stays 09:00 across a DST change (UTC instant shifts). Predefined utc/london/
paris. Plain events unaffected. 295/295 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:31:11 +00:00
c991c7c3d3 events: injected federation transport (fed-sx-ready) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
fetch abstracts how a peer's agenda arrives: (fetch peer-id ws we) ->
{:status :ok :occurrences} | {:status :error}. ev/federated-agenda-via merges
local + trusted peers fetched via the transport; unreachable peers degrade
gracefully. ev/peer-fetch = in-process adapter; ev/federation-status reports
reachability. A real fed-sx transport drops in unchanged. 278/278 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:12:37 +00:00
07e4cb5f4a events: reschedule notifications + 7 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
ev/reschedule-notifications: when an event carries per-occurrence overrides,
reads the roster at each overridden occurrence's original occ-key and emits a
reschedule message per booked attendee (old-start/new-start/new-duration).
Idempotency key = original-key/reschedule/new-start. 272/272 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:47:00 +00:00
98ed2eebdf events: booking lifecycle notifications + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
booking-notify.sx walks the booking stream into ordered notifications by kind
(booked/promoted/held/confirmed/released/cancelled/waitlisted). Promotion
detected by folding the waitlist (a booking for a waitlisted actor is a
promotion). id=occ-key/seq -> idempotent re-derivation, no double-ping.
Connects ticketing to the delivery layer. 265/265 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:20:39 +00:00
b308effb9f events: per-occurrence overrides / reschedule (RECURRENCE-ID) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
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
48f5b75cc2 events: RRULE EXDATE/RDATE exceptions + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
ev-event-full carries :exdate/:rdate. ev-expand-base = raw expansion;
ev-expand applies exceptions: RDATE adds in-window occurrences, EXDATE removes
matching starts, de-duped, EXDATE wins over RDATE and the rrule (RFC 5545).
RDATE-only events supported; plain ev-event unaffected. 248/248 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:26:15 +00:00
7446c24bde events: waitlist + auto-promotion + 21 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
When full, ev/waitlist! queues actors FIFO (:waitlist/:unwaitlist on the
booking stream; waiting fold independent of the seat fold). ev/waitlist,
ev/waitlist-position, ev/leave-waitlist!. ev/cancel-promote! frees a seat and
auto-promotes the head of the queue to a confirmed booking. Idempotent.
240/240 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:59:19 +00:00
29127d8613 events: federated free/busy across trusted peers + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
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 cross-instance availability,
half-open, trust-gated (untrusted peers ignored). 219/219 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:32:04 +00:00
80174c7197 events: Phase 4 federation — trust-gated peer agenda merge + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 17s
federation.sx: a peer publishes a schedule; ev/federated-agenda merges local
(origin :local) with trusted peers' agendas, sorted by start, tagged with
:origin provenance. Trust is a peer-id set re-checked per merge; untrusted
peers contribute nothing. Real transport slots behind ev/peer-agenda.
209/209 green — all four plan phases implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:59:12 +00:00
f6c1d1e9bf events: reminders + digests from the agenda + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
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 (sorted by fire-at),
ev/due-reminders (fire-at <= now), ev/reminder->msg (notify wire shape),
ev/agenda-digest + ev/agenda-for-p. 196/196 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:34:49 +00:00
e35769411e events: notification delivery flows on lib/flow + 7 tests (Phase 3 start)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
notify.sx: reminders + digests as durable flows over an injected transport.
A flow requests delivery (suspend); the host dispatch sends and resumes with
the outcome. At-least-once + idempotent (transport dedups by msg id; replay
logs outcomes). Retry rides suspend/resume with distinct per-attempt tags,
bounded by maxn. Digest delivers a batch with per-message outcomes.
182/182 green. Delivery core is the delivery-on-sx extraction seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:02:54 +00:00
05d5c46730 events: paid-ticket contract (commerce) over holds + 31 tests (Phase 2 done)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
ticket.sx: checkout-request (events->commerce) + payment-result
(commerce->events) wire shapes — commerce imports the contract. ev/request-
ticket! holds a seat + emits a checkout request; ev/settle-payment! confirms
on :paid, releases on failure/expiry. Idempotent; late paid for a vanished
hold -> :paid-but-no-hold (refund signal). 175/175 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:34:15 +00:00
7153e742c8 events: provisional holds (hold/confirm/release) for paid tickets + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Booking stream gains :hold/:confirm/:release; fold tracks per-actor seat state
(:held/:confirmed). A held seat counts toward capacity so a pending payment
can't be oversold. ev/hold! (capacity-safe), ev/confirm!, ev/release!,
ev/seat-state. Holds race test mirrors the booking race. 144/144 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:07:29 +00:00
24d4db3f0d events: wire persist-backed booking into api.sx + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Durable booking path alongside in-memory: ev/book-occ!, ev/cancel-occ!,
ev/roster-occ, ev/seats-left-occ (capacity from scheduled event); ev/free-p?,
ev/next-free-p, ev/conflicts-p derive availability by replaying persist
booking streams. Reordered conformance preloads. 120/120 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:39:19 +00:00
9adeff1431 events: booking cancellation + seat release + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Booking stream carries :booking/:cancel events; live roster is the folded
replay so cancelling frees a seat and capacity reopens. ev/cancel! (retrying
append-expect), no-op on unbooked, cancelled actor may re-book. Capacity count
is folded roster size. 110/110 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:09:58 +00:00
80a2dee22f events: capacity-safe transactional booking on persist + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
booking.sx: per-occurrence append-only stream, roster = replay. Booking
decided against an observed (roster, last-seq) snapshot, committed via
persist/append-expect — atomic check+append, no overbooking, no lock.
Explicit last-seat race test: two bookers, one booked, one conflict, roster
capped. Idempotent per actor. 97/97 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:44:43 +00:00
15e9503b05 events: api.sx — public events facade + 14 tests (Phase 1 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Immutable store ({:events :bookings}) over calendar+availability:
ev/schedule, ev/book, ev/agenda, ev/agenda-for, ev/free?, ev/next-free,
ev/conflicts. Availability queries auto-widen expansion by longest event.
73/73 green. Phase 1 done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:16:16 +00:00
4674b797cb events: next-free slot search + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
ev-next-free finds the earliest free slot >= after for a duration within a
horizon, probing 'after' + busy-interval ends via the busy_in rule (ev-free?).
Finds gaps, skips too-short gaps, half-open at edges. 59/59 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:49:42 +00:00
540933bfca events: availability.sx — free/busy + conflict detection on Datalog + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
occurrence/booking EDB; rules busy/conflict (canonical pair, half-open
overlap)/busy_in. API ev-busy, ev-conflicts, ev-has-conflict?, ev-free?
(transient qwindow). Integrates with calendar expansion. 53/53 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:23:51 +00:00
70aea21601 events: MONTHLY RRULE expansion (bymonthday + ordinal byday) + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
BYMONTHDAY (negative = from end), ordinal BYDAY ({:ord :wd}, last-weekday),
default day-of-month skipping short months. Weekly+monthly share ev-emit-occs.
37/37 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:52:39 +00:00
797c5f9147 events: Phase 1 calendar — DAILY/WEEKLY RRULE expansion + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Civil date arithmetic (Hinnant), integer epoch-minute datetimes, bounded
windowed RRULE expansion (DAILY/WEEKLY with INTERVAL/COUNT/UNTIL/BYDAY),
multi-event merge. Conformance harness + scoreboard wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:48:34 +00:00
d446562ed1 briefings: commerce / content / events / identity loop briefings
Authored from plans/{commerce,content,events,identity}-on-sx.md.
Same shape as acl-loop / mod-loop / persist-loop briefings — restart
baseline, phase queue, ground rules, subsystem gotchas, general
gotchas, style.

Substrate dependencies noted in each:
  commerce -> minikanren + persist + flow
  content  -> smalltalk + persist
  events   -> datalog + persist + flow
  identity -> erlang + persist + acl

Phase 1 of each is unblocked by the substrate that already exists;
later phases gate on persist (and friends) landing.
2026-06-06 23:25:15 +00:00
9f8e4d995d Merge loops/mod into architecture: mod-on-sx moderation engine on Prolog
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Moderation-on-Prolog layer in lib/mod: report schema, policy DSL (boolean algebra
+ count/score/reporters/burst conditions), proof-carrying engine, append-only
audit, lifecycle state machine + escalation/appeal, federation (advisory trust,
wire format, ActivityPub export), plus repeat-offender, quorum, temporal burst,
analytics (trace/whatif/lint/batch/explain/linking), domain policies, and an
end-to-end triage pipeline. Roadmap (4 phases) + 19 extensions, 390/390. Imports
lib/prolog only; Prolog unmodified.
2026-06-06 23:08:13 +00:00
4c8e732803 Merge loops/acl into architecture: acl-on-sx Datalog ACL
Fine-grained, explainable, federation-aware access control as a thin layer
over lib/datalog/. Four phases + hardening, 145/145 conformance:
- Phase 1 direct grants, deny-overrides via stratified negation
- Phase 2 inheritance (group/role member_of, resource child_of, role_grant)
- Phase 3 explanation (proof-tree reconstruction) + append-only audit log
- Phase 4 federation (trust-gated non-transitive delegation, revocation)
- hardening: diamonds, cycles, multi-peer, validation, audit save/restore

Surfaces the lib/guest/rules/ extraction seam (build-db/decide/explain/
revoke) for the second consumer (mod-on-sx). Records two substrate findings:
append! no-ops on map-derived lists; JIT loops on deep proof reconstruction
in warm processes (acl-explain only; acl-permit? unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:07:43 +00:00
9437f99e28 acl: hardening suite (+25) — diamonds, cycles, validation, audit save/restore
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
New adversarial/cross-phase coverage: diamond resource+group hierarchies
(deny wins per path), chain inheritance + leaf deny, cycle termination,
multi-peer delegation, fact validation, audit snapshot/restore round-trip.
Adds acl-validate-facts/acl-facts-valid? (schema) and acl-audit-snapshot/
restore!/copy (audit). Fixed acl-audit-restore! rebuilding the live log via
map (append! silently no-ops on map-derived lists).

Suite is prover-free: a substrate JIT bug loops the recursive proof
reconstructor on deep chains in warm processes (documented in Blockers);
acl-permit? is unaffected. 145/145.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:44:28 +00:00
98f5e1bf14 Merge loops/persist into architecture: persist-on-sx durable substrate
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
The shared durable-state substrate (lib/persist) other subsystems build on:
log + kv facets over an injectable backend, projections, subscriptions,
snapshots + compaction, optimistic concurrency, a durable backend over the
kernel perform IO boundary (blobs by reference), plus extensions (materialized
views, kv CAS, stream catalog, query helpers, atomic batch, schema-evolution
upcasters, exactly-once append, global commit ordering) and a worked ACL
reference migration. 201/201 tests across 20 suites. Durability awaits the
host-side storage adapter (tracked in the plan's Blockers; loops/host-persist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:21:27 +00:00
538b8a53e0 plans: shared-plumbing extraction note — defer to post-merge integration
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
mod-sx (Prolog) and acl-sx (Datalog) converged on the same module shape but run
on different engines. Only the audit log + fed trust/outbox shapes truly share;
extract at the architecture-merge point refactoring both consumers atomically,
not unilaterally from a loop branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:20:52 +00:00
7e732b1933 Merge loops/flow into architecture: flow-on-sx durable DAG workflow engine
166/166 across 11 suites, Phases 1-8. Combinators (sequence/parallel/branch/attempt/
map-flow/while/until + retry/timeout/try-catch/recover/tap/fail-model), durable
suspend/resume via deterministic replay (guest call/cc is escape-only), crash
recovery, fed-sx distribution (remote-node/failover/replication/handoff), operational
API + hygiene, and a host integration ABI + reference driver for art-dag / human-in-
the-loop. New lib/flow/** only; imports lib/scheme read-only.
2026-06-06 22:20:18 +00:00
200b93c1f6 persist: Blocker spec for the host durable-storage adapter
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Document the one gap to real durability: a hosts/ servicer for the persist/*
IO ops. Includes the silent-data-loss repro (durable-backend currently no-ops
under sx_server's default resolver), the full op contract table, hard
invariants (monotonic last-seq, etc.), the blob adapter shape, where to
register in sx_server.ml, and an acceptance test (swap transport, run durable +
recovery suites against real storage, survive a real restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:52:44 +00:00
84d5732b38 persist: worked reference migration — acl grants on persist + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
examples/acl.sx: a tested template migrating an ACL-grants store from a
hand-rolled ephemeral map to persist — grants/revokes as events, current set as
a projection, O(1) checks via a materialized view, audit via read-window.
Header carries the BEFORE->AFTER diff. Proves grants survive restart on the
durable backend (the capability the BEFORE version lacked). The pattern other
subsystem loops copy; does not touch the real lib/acl. 201/201.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:43:15 +00:00
a37a158d01 persist: global commit ordering across streams + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
global.sx: persist/gappend records a pointer in a reserved $global index whose
seq is the global commit position; read-global/project-global replay every
event in commit order; global-from for incremental consumers. Opt-in (plain
append untouched); $-prefixed streams now reserved + hidden from the public
catalog (streams-all reveals them). Gives feed its unified timeline.
Deterministic across restart. 191/191.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:41:01 +00:00
739e743918 mod: Ext 19 — end-to-end triage pipeline (capstone), 390/390
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
mod/triage-pipeline domain r reports actor composes domain-policy decision →
explanation → AP activity → wire into one bundle. Integration test runs the whole
federated path across 5 modules (decide → wire → peer → trust-gated apply),
confirming the module-by-module subsystem composes end to end. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:40:36 +00:00
c19f658cf2 mod: Ext 18 — ergonomic defrule / ruleset surface, 375/375
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
mod/defrule collects trailing conditions via &rest; mod/ruleset assembles rules.
No macro needed — conditions are plain data, fn supports &rest here. Produces
structurally identical rules to mk-rule (asserted) and works in the engine
unchanged. Closes the roadmap's original defrule surface. +11 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:37:12 +00:00
2f75ab11fc mod: Ext 17 — per-domain policy registry, 364/364
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
mod/register-policy! domain rules + mod/decide-in domain r reports give each
rose-ash domain its own rule set; unregistered domains fall back to default-rules
(never unmoderated). Same spam report → remove under a strict market policy, hide
under blog default. Engine already took rules as a param, so this is registry +
fallback, no engine change. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:33:24 +00:00
82fbf01bb3 mod: Ext 16 — ActivityPub-shaped decision export, 350/350
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
mod/decision->activity maps a decision to a moderation verb (remove→Delete,
ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity,
preserving the precise action. mod/decisions->activities batch-exports dropping
keeps. With wire (Ext 14) + fed trust (Phase 4) the federated moderation path is
end-to-end: decide → activity/wire → peer → trust-gate → apply. +17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:28:49 +00:00
3e90c780e9 persist: exactly-once append under retries + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
idempotency.sx: persist/append-once appends at most once per (stream,
idempotency key), returning the same event on a repeat. The marker lives in the
kv facet, so idempotency holds across a restart (verified on durable).
persist/seen? check. 180/180.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:28:21 +00:00
0f6dbdfc7d persist: event schema evolution via upcasters + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
upcast.sx: register a pure (event -> event) upcaster per type in an immutable
registry; read-upcast/project-upcast lift legacy events to the current shape on
read so projections see one shape (no version branching, no history rewrite).
upcast-data helper merges new :data fields. 171/171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:26:35 +00:00
62a1485302 persist: atomic batch append — contiguous block + transactional guard + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
batch.sx: persist/append-batch commits (type at data) specs as one contiguous
block; persist/append-batch-expect checks the stream is still at expected
before writing any event, so the batch is all-or-nothing under a concurrent
writer (conflict is a value, not a partial write). 162/162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:24:35 +00:00
329b3c4903 mod: Ext 15 — disjunctive (:any) conditions, 333/333
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
(:any (list c1 c2 ...)) compiles to Prolog disjunction (g1 ; g2 ; ...), completing
the condition boolean algebra (AND via :when list, :not, :any). cond->goal
recurses so combinators nest arbitrarily; the proof tree shows the compiled
disjunction verbatim. Maps onto Prolog's control constructs rather than
reimplementing boolean logic in SX. +10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:23:15 +00:00
4e521e3d7a persist: read-side query helpers — seq/time/type/predicate scans + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
query.sx: read-between (seq range), read-since/read-window (by :at),
read-by-type, read-where, count-where. Pure scans over persist/read for audit
windows, type filters, since-cursors. 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:22:03 +00:00
a00439da6e persist: stream catalog — enumerate streams + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
New backend op :streams (from seq high-water marks, so compacted streams still
list), threaded through mem-backend + durable serve/io-backend. catalog.sx:
persist/streams, stream-count, stream-exists?, total-events. 143/143.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:20:22 +00:00
8e16ba6b04 persist: kv compare-and-swap + create-only put + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
kv.sx: persist/kv-cas sets a key only if its current value equals expected,
else returns {:conflict :expected :actual}; persist/kv-put-new is create-only.
The kv analogue of log append-expect — atomic current-state for sessions, acl
grants, stock counts. 133/133.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:53 +00:00
919bd961d1 apl: migrate conformance onto shared lib/guest driver (counters mode)
Replaces the bespoke 116-line conformance.sh with a conformance.conf + 1-line
exec shim, reusing lib/guest/conformance.sh. Surfaced + fixed a silent undercount:
the old awk extractor reported pipeline=40, but pipeline.sx has 152 assertions —
real total is 562/562, not 450/450. Driver reads counter globals directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:28 +00:00
b43901d297 mod: Ext 14 — decision wire format for federation transport, 323/323
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
mod/decision->wire emits a versioned pipe-delimited line (MOD1|r1|hide|spam-hide);
mod/wire->decision parses it back (mod/wire-valid? guards). split-char built over
slice/len (loaded env has no split). Integration test runs the full federated
path: serialize → wire → deserialize → fed-receive-decision trust-gating
(untrusted→advisory, trusted→applied). +16 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:19 +00:00
ecdaeea223 persist: materialized views — stay current on write, O(1) read + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s
view.sx: persist/view bundles stream + fold + snapshot name; view-attach
subscribes it to a hub so each publish refreshes the snapshot incrementally,
making view-peek an O(1) current read. view-value always folds the tail so it
is never stale. The consumer read-model abstraction (feed indices, audit
rollups, search counters). 122/122.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:16:16 +00:00
4be6988963 persist: crash/restart recovery integration + migration notes — Phase 4 complete
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
recovery.sx: 6-test end-to-end crash/restart of an order ledger (log +
subscription kv read model + snapshot + compaction + invoice blob ref) on the
durable backend; everything survives a restart over the same disk + content
store, seq continues, two restarts converge. Migration notes (mem → durable
under a live subsystem) added to the plan. Roadmap done, 111/111.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:14:01 +00:00
1c7b602978 persist: blob backend — store the ref/CID, never the bytes + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
blob.sx: a blob ref is {:cid :size :mime}; the blob store is a separate
injected dependency (perform in prod, mock content store in tests).
persist/blob-store puts bytes and returns only the ref; bytes live in a
content-addressed store (artdag/IPFS). Tests assert refs in log/kv never carry
the bytes + content-address dedup. 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:48 +00:00
90c2a57975 persist: durable backend over the perform IO boundary + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
durable.sx: io-backend with an injectable transport — persist/durable-backend
performs each op as {:op "persist/..." :args (...)} (kernel suspends, host
resumes); persist/mock-durable services via persist/serve over an in-memory
disk. Identical request shapes mean the whole facet/projection/snapshot/
compaction stack runs unchanged on the durable backend. Crash/restart replay
recovers log+kv+snapshot. 91/91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:09:12 +00:00
68c8e39508 mod: Ext 13 — SLA sweep over pending lifecycle cases, 307/307
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 6m28s
Composes lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with
its state-entry tick; mod/overdue? flags pending cases (open/triaged/appealed)
past a deadline; mod/sla-sweep returns the breached report ids. Terminal states
never breach. Pure overlay — lifecycle stays timeless, caller stamps entry. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:08:37 +00:00
92addf5146 mod: Ext 12 — temporal burst detection, 292/292
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
Reports gain an :at tick (deterministic, supplied). mod/decide-temporal counts
reports about a subject within [now-window, now], asserts burst_count/2, and a
(:burst-at-least K) rule fires only on a real burst. 3 reports at 10/11/12 → hide;
3 at 1/2/12 (window 5) → keep, while the plain count rule escalates both. Fifth
report field threaded through rebuild helpers, non-breaking. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:00:51 +00:00
8292607e38 mod: Ext 11 — batch triage + corpus analytics, 277/277
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
mod/decide-batch triages a queue; mod/action-histogram summarizes outcomes by
action; mod/rule-coverage + mod/never-fired measure which rules fire across a
corpus — the empirical complement to lint's static unreachable check (lint finds
rules that can't fire; never-fired finds rules that didn't). +17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:56:19 +00:00
bf65de7b24 mod: Ext 10 — policy what-if / impact analysis, 260/260
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
mod/decision-diff compares one report's action under two rule sets;
mod/policy-impact batches a set and returns only the reports whose decision flips;
mod/impact-count / mod/impact-report summarize. Lets a mod team measure a policy
change's blast radius before shipping (e.g. removing spam-hide flips r1 hide→keep).
Pure SX over decide-report. +13 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:51:47 +00:00
3764b62206 mod: Ext 9 — policy dry-run trace diagnostics, 247/247
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
mod/trace-rules evaluates a report against every rule, returning each rule's
proved/unproved status + goal-by-goal derivation (an unproved rule shows which
goal failed). mod/first-proved = winner (matches engine precedence, cross-checked),
mod/proved-rules the firing set, mod/trace-report a [fires]/[ - ] rendering.
Answers 'why didn't my rule fire?' without instrumenting the engine. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:48:44 +00:00
062a76e64f mod: Ext 8 — quorum over distinct reporters (anti-brigade), 232/232
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
(:reporters-at-least N) compiles to setof(Br, report(_, Br, Sr), Bsr),
length(Bsr, Nr), Nr >= N — counts distinct reporters, not raw reports.
mod/decide-quorum asserts every report's report/3 fact (base engine scopes to the
decided report) so Prolog can aggregate reporters. One user filing 3 reports stays
:keep under quorum while the count rule escalates. Own suite. +9 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:45:28 +00:00
aff7d1e84f persist: compaction — drop snapshotted prefix, monotonic seq + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m0s
Backend now tracks last-seq as a monotonic high-water mark (survives
truncation) and exposes :truncate-through. compaction.sx: persist/compact
checkpoints then drops events with seq <= snapshot seq; should-compact?/
maybe-compact give an explicit every-N policy. Determinism: post-compaction
replay value == uncompacted full replay. Phase 3 complete, 76/76.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:42:06 +00:00
b0874b1282 persist: snapshots — checkpoint + replay = snapshot + tail + 11 tests
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
snapshot.sx: snapshot is a projection state {:value :seq} stored in kv under
snapshot/<name>. persist/checkpoint replays and saves; persist/replay folds
only the tail after the snapshot. Tests assert snapshot+tail == full replay
both ways + determinism. 65/65.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:39:41 +00:00
156d6f12ec persist: optimistic concurrency — conflict as a real result + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
concurrency.sx: persist/append-expect refuses an append when the stream
advanced past the caller's expected seq, returning {:conflict :expected
:actual} instead of crashing or overwriting. persist/conflict? + accessors.
Phase 2 complete, 54/54.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:37:49 +00:00
03da8d4328 persist: subscription hub — read models update on publish + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m6s
subscribe.sx: persist/hub wraps a backend; persist/publish appends then fires
per-stream callbacks (backend stream event). Direct persist/append bypasses
subscribers (bulk load/replay). Callbacks drive kv counters / project-resume. 46/46.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:36:16 +00:00
a6864178c3 persist: projections — fold stream into read model, incremental resume + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s
project.sx: projection state {:value :seq}; persist/project folds the whole
stream, persist/project-resume folds only the tail so read models update
incrementally. Pure step (value event)->value. 37/37.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:34:52 +00:00
314cc37030 persist: Phase 1 — log + kv facets on injectable in-memory backend + 28 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m3s
event/backend/log/kv/api over one injected backend protocol (mem default).
log: append/read/read-from, sequential per-stream seq, stream isolation.
kv: get/put/delete/has?/keys/get-or/update. conformance.sh + 3 suites, 28/28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:32:51 +00:00
50eb7079e5 briefings: mod-loop — cut/backtracking allowance + sx_write_file-first + loaded-env/not(Goal) gotchas
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m15s
Make explicit that the loop may lean on Prolog backtracking (pl-query-all) and cut,
preferring clause-order precedence via pl-query-one. Default to sx_write_file over
path/pattern edits; flag that sx_insert_near drops all but the first form. Document
the loaded-env primitive restriction (includes?/chars/etc. undefined after prolog
preloads; use the tokenizer's surviving set) and that negation is the not(Goal)
functor, not the prefix \+ operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:30:44 +00:00
c3668e4461 mod: Ext 7 — repeat-offender escalation (audit log as evidence), 223/223
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m13s
mod/subject-sanctions counts prior hide/remove/ban decisions about a subject from
the append-only audit log; mod/decide-escalating upgrades a sanction to :ban when
the subject has >= k priors. Non-sanction outcomes (keep/escalate) pass through.
Closes the loop between audit and policy — the trail feeds future decisions. Own
suite. +19 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:29:36 +00:00
b80cc32363 briefings: add persist-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:24:52 +00:00
01be84b5d8 mod: Ext 6 — strictest-wins decision strategy + action severity, 204/204
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m3s
mod/decide-strictest collects every proven rule (pl-query-all) and applies the
harshest action by mod/action-severity (keep<escalate<hide<remove<ban), an
alternative to the engine's first-match precedence. Diverges from first-match
exactly when rule order and severity disagree. Same decision shape + :strategy;
engine untouched. Own suite. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:20:15 +00:00
1902cce57f plans: rename store-on-sx → persist-on-sx; clarify it's persistence not shop, and scope (log+kv facets, blobs delegated, cache excluded)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:20:14 +00:00
e53a292f1a mod: Ext 5 — policy rule-set lint (unreachable/catch-all/dups), 190/190
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
Static analysis of a policy without running the engine: mod/unreachable-rules
flags rules after an unconditional rule (dead under first-match precedence),
mod/has-catchall? checks total coverage, mod/duplicate-rule-names + mod/rules-ok?
give a well-formedness verdict policy authors can assert. Own suite. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:15:41 +00:00
3d2c1d94f2 mod: Ext 4 — report linking + dedup (Prolog-backed retrieval), 176/176
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
mod/related-ids and mod/reporters-of find reports about a subject via a Prolog
relational query (report(Id, _, 'subject')) — the policy substrate reused for
retrieval. mod/dedup-reports collapses identical reports by a normalized
reporter|subject|reason key; mod/distinct-reporters-of counts unique reporters.
Own suite (tests/link.sx). +12 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:37 +00:00
102c806451 mod: Ext 3 — human-readable proof explanation (mod/explain), 164/164
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 47s
mod/explain renders a decision's proof tree into legible text: action + rule,
evidence line, and each derivation goal with [proved]/[unproved] and the
unification bindings that satisfied it (e.g. {B=ann, N=3, S=dave}). Pure SX over
the Phase-2 proof data — the audit trail's 'why' made readable. +10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:06:29 +00:00
779a592614 mod: Ext 2 — weighted/aggregate scoring (:score-at-least), 154/154
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Report :signals ({:kind :weight}) project to signal(Id, 'kind', weight) facts;
condition (:score-at-least N) compiles to aggregate_all(sum(W), signal(Id,_,W),T),
T >= N. Low-confidence signals accumulate past a threshold via genuine Prolog
arithmetic aggregation. Default policy untouched — proven via custom rule sets.
+8 extension tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:02:52 +00:00
2ea87796a1 mod: Ext 1 — negation-as-failure conditions (:not / :attr), 146/146
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Report attributes (:attrs) project to attr(Id, name) facts; policy gains (:attr x)
and (:not <cond>) conditions. The Prolog substrate exposes negation as a functor
not(Goal) (the prefix \+ operator doesn't parse here). Closed-world example:
hide spam unless author verified. Default policy untouched — feature proven via
custom rule sets, so all 132 base tests stay green. +14 extension tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:59:01 +00:00
ee9851c063 mod: Phase 4 — federation (trust, sharing, revocation), 132/132 — roadmap done
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
Cross-instance reports ingest into the local registry with origin tags; the
engine decides them unchanged. Decision sharing pushes to a mock fed-sx outbox
(mod/fed-send! is the transport seam). Trust is advisory by default: a peer's
decision binds locally only under (mod/trusted? peer :mod), else it lands in the
advisory log unapplied. Revocation composes with the Phase-2 proof model —
fed-revoke-if-invalidated re-runs the engine and undoes moderation only when the
action no longer holds (exoneration flips hide→keep → revoked + origin notified).
+26 fed tests. Full mod-on-sx roadmap complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:54:37 +00:00
f4f34c1d33 mod: Phase 3 — lifecycle state machine + escalation + appeal, 106/106
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Pure SX state machine (lib/mod/lifecycle.sx) over the engine:
open→triaged→decided→appealed→final, transition table guards illegal moves.
Auto-tier resolves terminal actions; escalate parks at human-tier (resolve
blocked until review supplies evidence). Appeal re-runs the engine — new
exonerated-keep rule at top precedence lets exoneration override a prior hide.
Api façade (mod/triage/resolve/review/appeal/finalize) over a case registry,
logging committed decisions to the audit trail. +46 escalation tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:50:05 +00:00
ff537bfba2 plans: six subsystem outline plans for the SX rewrite (store, commerce, identity, content, events, host)
Gap analysis from the five-subsystem set (acl/feed/flow/mod/search):
- store-on-sx: event-sourcing foundation the others fake with in-memory lists (build first)
- commerce-on-sx: catalog/cart/pricing/orders on miniKanren (+ store + flow)
- identity-on-sx: OAuth2/sessions/membership on Erlang (the core acl assumes)
- content-on-sx: documents/blocks/CRDT on Smalltalk
- events-on-sx: calendar/ticketing on Datalog + flow-driven delivery
- host-on-sx: the web boundary — off Quart onto native server+SXTP now, dream-on-sx next

All DRAFT outlines; substrate choices proposed, not final.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:29 +00:00
6e825e1283 mod: Phase 2 — evidence accumulation + proof trees + audit log, 60/60
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m7s
Reports carry an :evidence list, asserted as evidence/3 facts; reviewer-remove
rule (highest precedence) lets human review override classification. Proof tree
built constructively by re-querying each rule body goal against the same DB with
the report id bound, so derivations carry real unification bindings. Append-only
audit log records decision + proof + evidence snapshot per decide, monotonic seq,
never mutates prior entries. +29 audit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:37:02 +00:00
8dfc987095 mod: Phase 1 — report schema + policy engine on Prolog, 31/31
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
Reports → Prolog facts (report/3, classification/2, report_count/2); ordered
policy rules compile to policy_action/3 clauses, first match wins via
pl-query-one. Decisions carry their proof (matching rule + conditions +
evidence). Spam/abuse keyword classification, repeated-report escalation via
Prolog join+arithmetic, no-rule→keep default. Registry api + conformance harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:30:50 +00:00
1e4cf25015 Merge loops/feed into architecture: feed-on-sx activity feed engine on APL
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Activity feeds as APL array math on lib/apl/ — timelines, fanout, ranking,
visibility, federation. Roadmap (4 phases) + 8 extensions, 189/189 tests.

- Phase 1: stream model (normalize, filter/sort/take/reverse)
- Phase 2: fanout via outer product (∘.×), edge-guard, dedupe
- Phase 3: aggregation + ranking (recency/velocity/engagement, top-N)
- Phase 4: per-viewer ACL + federation (injected permit?/transport)
- Extensions: TF-IDF, notifications, home capstone, smart-dedupe,
  trending, mute, pagination, threading

Purely additive under lib/feed/**; no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:23:42 +00:00
72174941aa briefings: add mod-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:18:02 +00:00
9c4a5d1913 feed: conversation threading — :reply-to transitive closure (thread/replies/thread-size) + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:00:10 +00:00
f91ac82434 feed: pagination — offset/limit + cursor-by-at (before/after/page-before/next-cursor) + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:58:36 +00:00
5136249ae5 feed: viewer mute/block — mute actors/tags/objects + apply-prefs bag + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:05 +00:00
6fc61147a8 feed: trending objects/actors by recent activity window, deterministic tiebreak + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:55:55 +00:00
40be9cd074 acl: Phase 4 federation (trust-gated delegation, revocation) + 31 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 10m25s
federation.sx adds peer/trust/delegate/level_covers facts and one engine
rule: delegated grants apply only when local trust covers the action,
re-checked every query (non-transitive, fail-safe). Local/inherited deny
overrides federated grants; delegation composes with group and resource
inheritance. acl-revoke!/acl-fed-assert! propagate retraction/assertion;
mock fed-sx transport for tests. Federated proofs reconstruct via the
existing explainer. Roadmap complete: 120/120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:54:34 +00:00
0122c41ecb feed: verb-aware smart dedupe — reactions collapse cross-actor, posts stay per-actor + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:54:21 +00:00
58656b03e4 feed: feed/home capstone — fanout∘inbox∘dedupe∘ACL∘rank∘take as one line + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:53:15 +00:00
b0feb7b01b feed: notification feed — per-recipient inbox, verb filter, (verb,object) digest + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:51:53 +00:00
a979297959 feed: TF-IDF content ranking over :tags — tag-df/idf, tfidf-score, by-relevance + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:36 +00:00
37226cf6eb feed: Phase 4 visibility + federation — per-viewer ACL, fanout partition, inbound/backfill/ingest, e2e feed/timeline + 22 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:48:27 +00:00
15c97119e4 acl: Phase 3 explanation + audit, 35 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
explain.sx reconstructs a canonical proof tree (first-rule, first-solution)
by goal-directed search over the saturated db, since Datalog keeps no
provenance; depth-capped for cyclic safety. acl-explain returns
{:allowed? :proof :reason} with the blocking eff_deny proof on denial.
audit.sx is an append-only decision log (monotonic seq, disk serializer).
api gains acl/explain, acl/audit, acl/audit-tail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:47:07 +00:00
50a7f31a39 feed: Phase 3 aggregation + ranking — group-by, recency/velocity/engagement scorers, composite, top-N via stable grade-down + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:44:04 +00:00
915f51b2b6 feed: Phase 2 fanout via outer product — activities ∘.× audience, flatten, edge-guard, dedupe + 29 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:40:34 +00:00
9261d69cc5 acl: Phase 2 inheritance (groups, resource trees, roles) + 30 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
eff_grant/eff_deny derived relations inherit through member_of (group +
role membership) and child_of (resource hierarchy); role_grant confers
role capabilities. Deny-overrides via stratified negation, deny
authoritative across the inheritance closure. Cyclic membership
terminates. Phase 1 suite unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:36:24 +00:00
fe47334e52 acl: Phase 1 direct grants + deny-overrides, 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 56s
Datalog ACL layer (schema/facts/engine/api) over lib/datalog/. Direct
grant permits unless explicit deny names same (S,A,R) — deny-overrides
via stratified negation. Conformance wrapper + scoreboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:32:13 +00:00
e7501bdf8f feed: Phase 1 stream model — normalize, APL-backed filter/sort/take/reverse, post/all api + 30 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:31:36 +00:00
186 changed files with 21075 additions and 207 deletions

View File

@@ -1 +1 @@
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}

45
lib/acl/api.sx Normal file
View File

@@ -0,0 +1,45 @@
;; lib/acl/api.sx — public ACL surface over an implicit current db.
;;
;; Callers load a fact set once, then issue decisions without threading the db
;; through every call. The current db is module state; (acl/load! facts) rebuilds
;; it. This is the boundary the rest of rose-ash imports.
(define acl-current-db nil)
;; Replace the current fact base. Rebuilds the Datalog db under the active
;; ruleset (see lib/acl/engine.sx).
(define
acl/load!
(fn
(facts)
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
;; Ensure a db exists, building an empty one on first use.
(define
acl-ensure-db!
(fn
()
(do
(when
(= acl-current-db nil)
(set! acl-current-db (acl-build-db (list))))
acl-current-db)))
;; Public decision against the current db (pure, no logging).
(define
acl/permit?
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
(define
acl/explain
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
;; Audited decision: logs the outcome to the append-only audit log and returns
;; the boolean. See lib/acl/audit.sx.
(define
acl/audit
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
;; Recent audited decisions (chronological).
(define acl/audit-tail (fn (n) (acl-audit-tail n)))

110
lib/acl/audit.sx Normal file
View File

@@ -0,0 +1,110 @@
;; lib/acl/audit.sx — append-only decision log.
;;
;; Every decision routed through acl-audit-decide! is appended to an in-memory
;; log with a monotonic sequence number (no wall-clock — deterministic and
;; testable; a host can stamp time at the serializer boundary). The log is
;; append-only: there is no mutate or delete, only append, tail, clear,
;; snapshot/restore, and serialize-for-disk.
(define acl-audit-log (list))
(define acl-audit-seq 0)
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
;; log must always be a list built with `list` + `append!`.
(define
acl-audit-copy
(fn
(xs)
(let
((fresh (list)))
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
(define
acl-audit-clear!
(fn
()
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
;; Append a decision record. Returns the record.
(define
acl-audit-record!
(fn
(subj act res allowed?)
(let
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
(do
(set! acl-audit-seq (+ acl-audit-seq 1))
(append! acl-audit-log entry)
entry))))
;; Decide against db, log the outcome, and return the boolean. This is the
;; audited path; acl-permit? remains the pure, side-effect-free decision.
(define
acl-audit-decide!
(fn
(db subj act res)
(let
((allowed? (acl-permit? db subj act res)))
(do (acl-audit-record! subj act res allowed?) allowed?))))
(define acl-audit-count (fn () (len acl-audit-log)))
;; Most recent n entries (in chronological order). n >= log size returns all.
(define
acl-audit-tail
(fn
(n)
(let
((total (len acl-audit-log)))
(if
(<= total n)
acl-audit-log
(acl-audit-drop acl-audit-log (- total n))))))
(define
acl-audit-drop
(fn
(xs k)
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
;; copy of the log (so later appends don't mutate a held snapshot).
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
;; Replace the live log from a snapshot. Restores both entries and the seq
;; counter so subsequent records continue numbering correctly. The log is
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
(define
acl-audit-restore!
(fn
(snap)
(do
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
(set! acl-audit-seq (get snap :seq))
nil)))
;; Serialize the whole log to a disk-ready string: one record per line,
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
;; snapshot/restore.
(define
acl-audit-serialize
(fn
()
(reduce
(fn
(acc e)
(str
acc
(get e :seq)
"\t"
(get e :subj)
"\t"
(get e :act)
"\t"
(get e :res)
"\t"
(get e :allowed?)
"\n"))
""
acl-audit-log)))

32
lib/acl/conformance.conf Normal file
View File

@@ -0,0 +1,32 @@
# ACL conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=acl
MODE=dict
PRELOADS=(
lib/datalog/tokenizer.sx
lib/datalog/parser.sx
lib/datalog/unify.sx
lib/datalog/db.sx
lib/datalog/builtins.sx
lib/datalog/aggregates.sx
lib/datalog/strata.sx
lib/datalog/eval.sx
lib/datalog/api.sx
lib/datalog/magic.sx
lib/acl/schema.sx
lib/acl/facts.sx
lib/acl/engine.sx
lib/acl/explain.sx
lib/acl/audit.sx
lib/acl/federation.sx
lib/acl/api.sx
)
SUITES=(
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
)

3
lib/acl/conformance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

72
lib/acl/engine.sx Normal file
View File

@@ -0,0 +1,72 @@
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
;;
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
;; reduces a (subject, action, resource) decision to a Datalog query against a
;; db built from EDB facts. The rule engine itself is Datalog's.
;;
;; Policy — inheritance + federation with deny-overrides:
;;
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
;; trust(Peer,L), level_covers(L,A).
;;
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
;;
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
;;
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
;; (S,A,R) defeats any effective grant — including federated grants. Deny
;; inherits through the *same* group and resource chains as grant, so a
;; group-level or ancestor-resource deny is authoritative for members/
;; descendants. This is the principled, fail-safe reading of "deny wins".
;;
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
;; Trust is re-checked on every query (it is a body literal), never baked in at
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
;; immediately on the next decision.
;;
;; Termination & stratification:
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
;; EDB relations with no function symbols, so the closure is finite (cyclic
;; membership/containment just reaches a fixpoint, never loops). The
;; federation rule is non-recursive.
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
;; permit, so the program is stratifiable (permit sits in a higher stratum).
(define
acl-rules
(quote
((eff_grant S A R <- (grant S A R))
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
(eff_grant
S
A
R
<-
(delegate Peer S A R)
(trust Peer L)
(level_covers L A))
(eff_deny S A R <- (deny S A R))
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
;; Core decision: does the db permit subject S to perform action A on
;; resource R? Reduces to a ground Datalog query on the derived `permit`
;; relation — non-empty result means permitted.
(define
acl-permit?
(fn
(db subj act res)
(> (len (dl-query db (list (quote permit) subj act res))) 0)))

125
lib/acl/explain.sx Normal file
View File

@@ -0,0 +1,125 @@
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
;;
;; lib/datalog/ records derived facts but not their provenance, so the proof is
;; reconstructed here by goal-directed search over the *saturated* db: for a
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
;; the first solution binding its remaining variables, and recurse on each body
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
;;
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
;; many ways). We pick ONE canonical proof — first matching rule, first solution
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
;; depth cap guards against pathological cyclic data producing unbounded search.
;;
;; A proof node is one of:
;; {:fact <lit> :via "edb"} — base EDB fact
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
;; {:neg-ok <lit>} — negation verified to fail
;; {:fact <lit> :truncated true} — depth cap hit
(define acl-proof-max-depth 64)
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
;; does not recurse into dicts, which would leak the neg's free vars).
(define
acl-subst-lit
(fn
(lit s)
(if
(and (dict? lit) (has-key? lit :neg))
{:neg (dl-apply-subst (get lit :neg) s)}
(dl-apply-subst lit s))))
(define
acl-lit-edb?
(fn
(lit)
(and
(list? lit)
(> (len lit) 0)
(symbol? (first lit))
(has-key? acl-edb-arity (symbol->string (first lit))))))
(define
acl-subst-zip!
(fn
(d ks vs)
(when
(> (len ks) 0)
(do
(dict-set! d (symbol->string (first ks)) (first vs))
(acl-subst-zip! d (rest ks) (rest vs))))))
;; Bind a rule head's variables to a ground goal's arguments (positional).
(define
acl-bind-head
(fn
(head goal)
(let
((d {}))
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
(define
acl-subst-union
(fn
(a b)
(let
((d {}))
(do
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
d))))
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
(define
acl-prove-d
(fn
(db goal depth)
(cond
((> depth acl-proof-max-depth) {:truncated true :fact goal})
((acl-lit-edb? goal)
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
(else (acl-prove-rules db goal acl-rules depth)))))
(define
acl-prove-rules
(fn
(db goal rules depth)
(if
(= (len rules) 0)
nil
(let
((p (dl-rule-from-list (first rules))))
(if
(= (first (get p :head)) (first goal))
(let
((hs (acl-bind-head (get p :head) goal)))
(let
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
(let
((sols (dl-query db qbody)))
(if
(> (len sols) 0)
(acl-prove-build db goal p hs (first sols) depth)
(acl-prove-rules db goal (rest rules) depth)))))
(acl-prove-rules db goal (rest rules) depth))))))
(define
acl-prove-build
(fn
(db goal p hs sol depth)
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
;; Public decision-with-proof. Returns:
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
;; inherited deny), else nil (simply no grant).
(define
acl-explain
(fn
(db subj act res)
(let
((proof (acl-prove db (list (quote permit) subj act res))))
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))

47
lib/acl/facts.sx Normal file
View File

@@ -0,0 +1,47 @@
;; lib/acl/facts.sx — EDB fact constructors.
;;
;; Each constructor returns a Datalog fact tuple (a list whose head is the
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
;; lib/datalog/.
;; Phase 1: actor/resource/grant/deny.
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
;; role_grant (role -> action,resource capability).
;; Phase 4: peer/trust/delegate/level_covers (federation).
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
;; subject S is a member of group/role G (one hop; transitivity is derived).
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
;; resource R is a child of parent P (one hop; transitivity is derived).
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
;; role confers capability (act on res) to every member of the role.
(define
acl-role-grant
(fn (role act res) (list (quote role_grant) role act res)))
;; --- federation ---
;; a known peer instance at addr, of some kind (e.g. peer).
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
;; local trust in a peer at a named level. Gates delegated grants at query time.
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
;; that peer covers action A (see level_covers).
(define
acl-delegate
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
;; local policy: trust `level` authorises delegated grants for action `act`.
(define
acl-level-covers
(fn (level act) (list (quote level_covers) level act)))

61
lib/acl/federation.sx Normal file
View File

@@ -0,0 +1,61 @@
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
;;
;; fed-sx replicates ACL facts between instances; this module models the local
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
;; authorise them (enforced by the engine rule, re-checked every query). The
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
;;
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
;; name) to the list of delegate facts that peer asserts. Returns the facts for
;; `addr`, or an empty list if the peer is unknown / unreachable.
(define
acl-fed-fetch
(fn
(transport addr)
(let
((k (if (symbol? addr) (symbol->string addr) addr)))
(if (has-key? transport k) (get transport k) (list)))))
;; Gather delegate facts from every peer in `addrs` via the transport.
(define
acl-fed-collect
(fn
(transport addrs)
(let
((acc (list)))
(do
(for-each
(fn
(addr)
(for-each
(fn (f) (append! acc f))
(acl-fed-fetch transport addr)))
addrs)
acc))))
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
;; facts must include the `trust`/`level_covers` policy; replicated delegate
;; facts are gated against it by the engine rule at query time.
(define
acl-fed-build-db
(fn
(local-facts transport peers)
(let
((all (list)))
(do
(for-each (fn (f) (append! all f)) local-facts)
(for-each
(fn (f) (append! all f))
(acl-fed-collect transport peers))
(acl-build-db all)))))
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
;; local trust) from a live db. The next decision re-saturates and reflects it.
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
;; Propagated assertion: ingest a newly replicated fact into a live db.
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))

71
lib/acl/schema.sx Normal file
View File

@@ -0,0 +1,71 @@
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
;;
;; Datalog is untyped; this module is the schema-as-data layer. It declares
;; the subject/resource/action sorts and the arity of every EDB predicate the
;; ACL engine recognises, plus light validators. Facts that pass these checks
;; are well-formed inputs to lib/acl/engine.sx.
(define acl-subject-kinds (quote (user group role service)))
(define acl-resource-kinds (quote (page post thread peer)))
;; Actions are open-ended (a grant may name any action symbol), but these are
;; the platform's well-known verbs.
(define acl-actions (quote (read edit comment moderate federate)))
;; EDB predicate name -> arity.
;; Phase 1: actor/resource/grant/deny.
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
;; role_grant (role->action,resource).
;; Phase 4: peer (addr->kind), trust (peer->level),
;; delegate (peer->subj,action,resource), level_covers (level->action).
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
(define
acl-member?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (acl-member? x (rest xs))))))
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
;; is known and the argument count matches the declared arity.
(define
acl-fact-valid?
(fn
(f)
(and
(list? f)
(> (len f) 0)
(symbol? (first f))
(let
((pred (symbol->string (first f))))
(and
(has-key? acl-edb-arity pred)
(= (- (len f) 1) (get acl-edb-arity pred)))))))
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
;; tuple, and custom action symbols are allowed); callers opt in to checking.
(define
acl-validate-facts
(fn
(facts)
(let
((bad (list)))
(do
(for-each
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
facts)
bad))))
(define
acl-facts-valid?
(fn (facts) (= (len (acl-validate-facts facts)) 0)))

14
lib/acl/scoreboard.json Normal file
View File

@@ -0,0 +1,14 @@
{
"lang": "acl",
"total_passed": 145,
"total_failed": 0,
"total": 145,
"suites": [
{"name":"direct","passed":24,"failed":0,"total":24},
{"name":"inherit","passed":30,"failed":0,"total":30},
{"name":"explain","passed":35,"failed":0,"total":35},
{"name":"fed","passed":31,"failed":0,"total":31},
{"name":"harden","passed":25,"failed":0,"total":25}
],
"generated": "2026-06-06T22:43:27+00:00"
}

11
lib/acl/scoreboard.md Normal file
View File

@@ -0,0 +1,11 @@
# acl scoreboard
**145 / 145 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
| direct | 24 | 24 | ok |
| inherit | 30 | 30 | ok |
| explain | 35 | 35 | ok |
| fed | 31 | 31 | ok |
| harden | 25 | 25 | ok |

170
lib/acl/tests/direct.sx Normal file
View File

@@ -0,0 +1,170 @@
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
(define acl-dt-pass 0)
(define acl-dt-fail 0)
(define acl-dt-failures (list))
(define
acl-dt-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-dt-pass (+ acl-dt-pass 1))
(do
(set! acl-dt-fail (+ acl-dt-fail 1))
(append!
acl-dt-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A small fixture used by most cases: alice can read page1, is denied edit on
;; page1, and a service may federate peer1.
(define
acl-dt-fixture
(fn
()
(acl-build-db
(list
(acl-actor (quote alice) (quote user))
(acl-actor (quote svc1) (quote service))
(acl-resource-fact (quote page1) (quote page))
(acl-resource-fact (quote peer1) (quote peer))
(acl-grant (quote alice) (quote read) (quote page1))
(acl-grant (quote alice) (quote edit) (quote page1))
(acl-deny (quote alice) (quote edit) (quote page1))
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
(define
acl-dt-run-all!
(fn
()
(let
((db (acl-dt-fixture)))
(do
(acl-dt-check!
"direct grant permits"
(acl-permit? db (quote alice) (quote read) (quote page1))
true)
(acl-dt-check!
"service grant permits federate"
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
true)
(acl-dt-check!
"missing action denied"
(acl-permit? db (quote alice) (quote comment) (quote page1))
false)
(acl-dt-check!
"missing resource denied"
(acl-permit? db (quote alice) (quote read) (quote page2))
false)
(acl-dt-check!
"missing subject denied"
(acl-permit? db (quote bob) (quote read) (quote page1))
false)
(acl-dt-check!
"wrong subject for service grant denied"
(acl-permit? db (quote alice) (quote federate) (quote peer1))
false)
(acl-dt-check!
"grant plus deny -> deny wins"
(acl-permit? db (quote alice) (quote edit) (quote page1))
false)
(acl-dt-check!
"deny alone still denies"
(acl-permit?
(acl-build-db
(list (acl-deny (quote alice) (quote read) (quote page1))))
(quote alice)
(quote read)
(quote page1))
false)
(acl-dt-check!
"deny on edit does not block read"
(acl-permit? db (quote alice) (quote read) (quote page1))
true)
(acl-dt-check!
"empty db denies"
(acl-permit?
(acl-build-db (list))
(quote alice)
(quote read)
(quote page1))
false)
(let
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
(do
(acl-dt-check!
"subject a allowed"
(acl-permit? db2 (quote a) (quote read) (quote r))
true)
(acl-dt-check!
"subject b denied by override"
(acl-permit? db2 (quote b) (quote read) (quote r))
false)))
(let
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
(acl-dt-check!
"role subject direct grant"
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
true))
(do
(acl/load!
(list
(acl-grant (quote carol) (quote moderate) (quote thread1))))
(acl-dt-check!
"api permit via current db"
(acl/permit? (quote carol) (quote moderate) (quote thread1))
true)
(acl-dt-check!
"api deny via current db"
(acl/permit? (quote carol) (quote read) (quote thread1))
false))
(do
(acl/load! (list))
(acl-dt-check!
"api reload clears prior grants"
(acl/permit? (quote carol) (quote moderate) (quote thread1))
false))
(acl-dt-check!
"schema grant arity valid"
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
true)
(acl-dt-check!
"schema bad arity invalid"
(acl-fact-valid? (list (quote grant) (quote x)))
false)
(acl-dt-check!
"schema unknown predicate invalid"
(acl-fact-valid? (list (quote frobnicate) (quote x)))
false)
(acl-dt-check!
"schema subject kind known"
(acl-subject-kind? (quote service))
true)
(acl-dt-check!
"schema resource kind unknown"
(acl-resource-kind? (quote galaxy))
false)
(acl-dt-check!
"schema known action"
(acl-known-action? (quote moderate))
true)
(acl-dt-check!
"grant constructor shape"
(acl-grant (quote u) (quote read) (quote p))
(list (quote grant) (quote u) (quote read) (quote p)))
(acl-dt-check!
"actor constructor shape"
(acl-actor (quote u) (quote user))
(list (quote actor) (quote u) (quote user)))))))
(define
acl-direct-tests-run!
(fn
()
(do
(set! acl-dt-pass 0)
(set! acl-dt-fail 0)
(set! acl-dt-failures (list))
(acl-dt-run-all!)
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))

316
lib/acl/tests/explain.sx Normal file
View File

@@ -0,0 +1,316 @@
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
(define acl-et-pass 0)
(define acl-et-fail 0)
(define acl-et-failures (list))
;; Name-based deep equality. The host `=` compares symbols by interned
;; identity, which is unstable across substitution/saturation; comparing by
;; name (as the datalog suite does) makes structural assertions deterministic.
(define
acl-et-eq?
(fn
(a b)
(cond
((and (list? a) (list? b))
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
((and (dict? a) (dict? b))
(let
((ka (keys a)) (kb (keys b)))
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
((and (symbol? a) (symbol? b))
(= (symbol->string a) (symbol->string b)))
(else (= a b)))))
(define
acl-et-eq-l?
(fn
(a b i)
(cond
((>= i (len a)) true)
((not (acl-et-eq? (nth a i) (nth b i))) false)
(else (acl-et-eq-l? a b (+ i 1))))))
(define
acl-et-eq-d?
(fn
(a b ka i)
(cond
((>= i (len ka)) true)
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
false)
(else (acl-et-eq-d? a b ka (+ i 1))))))
(define
acl-et-check!
(fn
(name got expected)
(if
(acl-et-eq? got expected)
(set! acl-et-pass (+ acl-et-pass 1))
(do
(set! acl-et-fail (+ acl-et-fail 1))
(append!
acl-et-failures
(str name "\n expected: " expected "\n got: " got))))))
;; --- proof-tree walkers ---
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
(define
acl-et-has-leaf?
(fn
(node target)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :via))
(acl-et-eq? (get node :fact) target))
((and (dict? node) (has-key? node :body))
(acl-et-any-leaf? (get node :body) target))
(else false))))
(define
acl-et-any-leaf?
(fn
(nodes target)
(cond
((= (len nodes) 0) false)
((acl-et-has-leaf? (first nodes) target) true)
(else (acl-et-any-leaf? (rest nodes) target)))))
;; True if the proof records a verified negation (deny did not fire).
(define
acl-et-has-negok?
(fn
(node)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :neg-ok)) true)
((and (dict? node) (has-key? node :body))
(acl-et-any-negok? (get node :body)))
(else false))))
(define
acl-et-any-negok?
(fn
(nodes)
(cond
((= (len nodes) 0) false)
((acl-et-has-negok? (first nodes)) true)
(else (acl-et-any-negok? (rest nodes))))))
(define
acl-et-run-all!
(fn
()
(do
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
(let
((e (acl-explain db (quote u) (quote read) (quote p))))
(do
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
(acl-et-check!
"direct: proof root fact"
(get (get e :proof) :fact)
(list (quote permit) (quote u) (quote read) (quote p)))
(acl-et-check!
"direct: grant leaf present"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote u) (quote read) (quote p)))
true)
(acl-et-check!
"direct: negation verified"
(acl-et-has-negok? (get e :proof))
true)
(acl-et-check!
"direct: reason nil when allowed"
(get e :reason)
nil))))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-et-check! "group: allowed?" (get e :allowed?) true)
(acl-et-check!
"group: member_of alice leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote alice) (quote team)))
true)
(acl-et-check!
"group: member_of team leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote team) (quote org)))
true)
(acl-et-check!
"group: grant org leaf at base"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote org) (quote read) (quote doc)))
true))))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
(let
((e (acl-explain db (quote u) (quote read) (quote sec))))
(do
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
(acl-et-check!
"resource: child_of leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote child_of) (quote sec) (quote book)))
true)
(acl-et-check!
"resource: grant on parent leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote u) (quote read) (quote book)))
true))))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
(let
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
(do
(acl-et-check! "role: allowed?" (get e :allowed?) true)
(acl-et-check!
"role: member_of leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote bob) (quote editor)))
true)
(acl-et-check!
"role: role_grant leaf"
(acl-et-has-leaf?
(get e :proof)
(list
(quote role_grant)
(quote editor)
(quote edit)
(quote page1)))
true))))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(let
((e (acl-explain db (quote u) (quote edit) (quote p))))
(do
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
(acl-et-check! "deny: no proof" (get e :proof) nil)
(acl-et-check!
"deny: reason root is eff_deny"
(get (get e :reason) :fact)
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
(acl-et-check!
"deny: reason has deny leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote deny) (quote u) (quote edit) (quote p)))
true))))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-et-check!
"inherited deny: not allowed"
(get e :allowed?)
false)
(acl-et-check!
"inherited deny: reason has member_of leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote member_of) (quote alice) (quote team)))
true)
(acl-et-check!
"inherited deny: reason has group deny leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote deny) (quote team) (quote read) (quote doc)))
true))))
(let
((db (acl-build-db (list))))
(let
((e (acl-explain db (quote u) (quote read) (quote p))))
(do
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(do
(acl-audit-clear!)
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
(acl-et-check!
"audit decide allowed returns true"
(acl-audit-decide! db (quote u) (quote read) (quote p))
true)
(acl-et-check!
"audit decide denied returns false"
(acl-audit-decide! db (quote u) (quote edit) (quote p))
false)
(acl-audit-decide! db (quote u) (quote comment) (quote p))
(acl-et-check!
"audit: count after three decisions"
(acl-audit-count)
3)
(acl-et-check!
"audit: tail size respects n"
(len (acl-audit-tail 2))
2)
(acl-et-check!
"audit: tail returns most recent"
(get (first (acl-audit-tail 1)) :act)
(quote comment))
(acl-et-check!
"audit: first record seq is 0"
(get (first (acl-audit-tail 3)) :seq)
0)
(acl-et-check!
"audit: allowed flag recorded"
(get (first (acl-audit-tail 3)) :allowed?)
true)
(acl-et-check!
"audit: serialize line count"
(len (acl-et-lines (acl-audit-serialize)))
3)
(acl-audit-clear!)
(acl-et-check!
"audit: clear resets count"
(acl-audit-count)
0))))))
;; count newline-terminated lines in a serialized log
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
(define
acl-et-count-nl
(fn
(s i n)
(if
(>= i (len s))
(if (= n 0) (list) (acl-et-rangelist n))
(acl-et-count-nl
s
(+ i 1)
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
(define
acl-et-rangelist
(fn
(n)
(if
(<= n 0)
(list)
(cons n (acl-et-rangelist (- n 1))))))
(define
acl-explain-tests-run!
(fn
()
(do
(set! acl-et-pass 0)
(set! acl-et-fail 0)
(set! acl-et-failures (list))
(acl-et-run-all!)
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))

273
lib/acl/tests/fed.sx Normal file
View File

@@ -0,0 +1,273 @@
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
(define acl-ft-pass 0)
(define acl-ft-fail 0)
(define acl-ft-failures (list))
;; Name-based deep equality (host `=` compares symbols by unstable interned
;; identity; see lib/acl/tests/explain.sx).
(define
acl-ft-eq?
(fn
(a b)
(cond
((and (list? a) (list? b))
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
((and (symbol? a) (symbol? b))
(= (symbol->string a) (symbol->string b)))
(else (= a b)))))
(define
acl-ft-eq-l?
(fn
(a b i)
(cond
((>= i (len a)) true)
((not (acl-ft-eq? (nth a i) (nth b i))) false)
(else (acl-ft-eq-l? a b (+ i 1))))))
(define
acl-ft-check!
(fn
(name got expected)
(if
(acl-ft-eq? got expected)
(set! acl-ft-pass (+ acl-ft-pass 1))
(do
(set! acl-ft-fail (+ acl-ft-fail 1))
(append!
acl-ft-failures
(str name "\n expected: " expected "\n got: " got))))))
;; proof leaf walker (federated proofs reconstruct through the engine rule).
(define
acl-ft-has-leaf?
(fn
(node target)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :via))
(acl-ft-eq? (get node :fact) target))
((and (dict? node) (has-key? node :body))
(acl-ft-any-leaf? (get node :body) target))
(else false))))
(define
acl-ft-any-leaf?
(fn
(nodes target)
(cond
((= (len nodes) 0) false)
((acl-ft-has-leaf? (first nodes) target) true)
(else (acl-ft-any-leaf? (rest nodes) target)))))
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
;; A standard federation fixture: local trusts peer alpha at "readonly", which
;; covers read+comment. alpha delegates several capabilities to alice.
(define
acl-ft-fixture
(fn
()
(acl-build-db
(list
(acl-trust (quote alpha) (quote readonly))
(acl-level-covers (quote readonly) (quote read))
(acl-level-covers (quote readonly) (quote comment))
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
(define
acl-ft-run-all!
(fn
()
(do
(let
((db (acl-ft-fixture)))
(do
(acl-ft-check!
"trusted delegate, level covers action -> permit"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"trusted delegate, level does NOT cover action -> deny"
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
false)
(acl-ft-check!
"delegated but action class uncovered (comment has no delegate)"
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-ft-check!
"untrusted peer delegate -> deny"
(acl-ft-p? db (quote bob) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(acl-ft-check!
"trust but no level_covers -> deny"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(do
(acl-ft-check!
"trust is per-peer: alpha's delegate applies"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"trust not transitive: beta's delegate does not apply"
(acl-ft-p? db (quote bob) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-ft-check!
"local deny overrides federated grant"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
(acl-ft-check!
"federated grant to group reaches member"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
(acl-ft-check!
"federated grant on parent resource reaches child"
(acl-ft-p? db (quote u) (quote read) (quote sec))
true))
(let
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
(do
(acl-ft-check!
"fetch known peer returns its delegates"
(len (acl-fed-fetch transport (quote alpha)))
1)
(acl-ft-check!
"fetch unknown peer returns empty"
(len (acl-fed-fetch transport (quote delta)))
0)
(acl-ft-check!
"collect across peers"
(len
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
2)
(let
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
(do
(acl-ft-check!
"fed-build-db: alpha delegate permits"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"fed-build-db: gamma delegate permits"
(acl-ft-p? db (quote carol) (quote read) (quote post))
true)
(acl-ft-check!
"fed-build-db: untrusted action still denied"
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
false)))))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"before revoke: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-revoke!
db
(acl-delegate
(quote alpha)
(quote alice)
(quote read)
(quote doc)))
(acl-ft-check!
"after delegate revoked: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"before trust revoke: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
(acl-ft-check!
"after trust revoked: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"delegate without trust: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
(acl-ft-check!
"trust ingested then re-checked: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)))
(let
((db (acl-ft-fixture)))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
(acl-ft-check!
"federated proof has delegate leaf"
(acl-ft-has-leaf?
(get e :proof)
(list
(quote delegate)
(quote alpha)
(quote alice)
(quote read)
(quote doc)))
true)
(acl-ft-check!
"federated proof has trust leaf"
(acl-ft-has-leaf?
(get e :proof)
(list (quote trust) (quote alpha) (quote readonly)))
true)
(acl-ft-check!
"federated proof has level_covers leaf"
(acl-ft-has-leaf?
(get e :proof)
(list (quote level_covers) (quote readonly) (quote read)))
true))))
(acl-ft-check!
"schema delegate arity valid"
(acl-fact-valid?
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
true)
(acl-ft-check!
"schema trust arity valid"
(acl-fact-valid? (acl-trust (quote p) (quote l)))
true)
(acl-ft-check!
"schema peer arity valid"
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
true)
(acl-ft-check!
"schema level_covers arity valid"
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
true)
(acl-ft-check!
"schema delegate bad arity invalid"
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
false))))
(define
acl-fed-tests-run!
(fn
()
(do
(set! acl-ft-pass 0)
(set! acl-ft-fail 0)
(set! acl-ft-failures (list))
(acl-ft-run-all!)
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))

228
lib/acl/tests/harden.sx Normal file
View File

@@ -0,0 +1,228 @@
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
;;
;; Diamond hierarchies, conflict resolution where deny must win through every
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
;; validation, and audit save/restore.
;;
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
;; reconstruction is covered by tests/explain.sx (and federated proofs by
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
(define acl-hd-pass 0)
(define acl-hd-fail 0)
(define acl-hd-failures (list))
(define
acl-hd-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-hd-pass (+ acl-hd-pass 1))
(do
(set! acl-hd-fail (+ acl-hd-fail 1))
(append!
acl-hd-failures
(str name "\n expected: " expected "\n got: " got))))))
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
(define
acl-hd-run-all!
(fn
()
(do
(let
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
(both-grant
(acl-build-db
(list
(acl-child-of (quote r) (quote p1))
(acl-child-of (quote r) (quote p2))
(acl-grant (quote u) (quote read) (quote p1))
(acl-grant (quote u) (quote read) (quote p2))))))
(do
(acl-hd-check!
"diamond resource: grant+deny parents -> deny wins"
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
false)
(acl-hd-check!
"diamond resource: both grant -> permit"
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
true)
(acl-hd-check!
"diamond resource: deny does not leak to other parent"
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
true)))
(let
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
(both-grant
(acl-build-db
(list
(acl-member-of (quote alice) (quote g1))
(acl-member-of (quote alice) (quote g2))
(acl-grant (quote g1) (quote read) (quote doc))
(acl-grant (quote g2) (quote read) (quote doc))))))
(do
(acl-hd-check!
"diamond group: grant+deny groups -> deny wins"
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
false)
(acl-hd-check!
"diamond group: both grant -> permit"
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
true)))
(let
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
(chain-deny
(acl-build-db
(list
(acl-member-of (quote a0) (quote a1))
(acl-member-of (quote a1) (quote a2))
(acl-member-of (quote a2) (quote a3))
(acl-member-of (quote a3) (quote a4))
(acl-grant (quote a4) (quote read) (quote res))
(acl-deny (quote a0) (quote read) (quote res))))))
(do
(acl-hd-check!
"chain: top-group grant reaches leaf member"
(acl-hd-p? chain (quote a0) (quote read) (quote res))
true)
(acl-hd-check!
"chain: intermediate also covered"
(acl-hd-p? chain (quote a2) (quote read) (quote res))
true)
(acl-hd-check!
"chain: leaf-member deny overrides top grant"
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
false)
(acl-hd-check!
"chain: deny on leaf does not block sibling level"
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
true)))
(let
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
(self-child
(acl-build-db
(list
(acl-child-of (quote r) (quote r))
(acl-grant (quote u) (quote read) (quote r)))))
(two-cycle
(acl-build-db
(list
(acl-member-of (quote x) (quote y))
(acl-member-of (quote y) (quote x))
(acl-grant (quote y) (quote read) (quote r))))))
(do
(acl-hd-check!
"self-membership cycle terminates and grants"
(acl-hd-p? self-member (quote a) (quote read) (quote r))
true)
(acl-hd-check!
"self-child cycle terminates and grants"
(acl-hd-p? self-child (quote u) (quote read) (quote r))
true)
(acl-hd-check!
"two-node membership cycle terminates"
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
true)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-hd-check!
"federated group grant, local member deny -> deny wins"
(acl-hd-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-hd-check!
"two peers delegate, one trusted -> permit"
(acl-hd-p? db (quote bob) (quote read) (quote doc))
true))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-hd-check!
"two peers both trusted -> permit"
(acl-hd-p? db (quote bob) (quote read) (quote doc))
true))
(let
((empty (acl-build-db (list))))
(acl-hd-check!
"empty db: nothing permitted"
(acl-hd-p? empty (quote u) (quote read) (quote r))
false))
(do
(acl-hd-check!
"validate: clean set has no bad facts"
(len
(acl-validate-facts
(list
(acl-grant (quote u) (quote read) (quote p))
(acl-member-of (quote u) (quote g))
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
0)
(acl-hd-check!
"validate: facts-valid? true on clean set"
(acl-facts-valid?
(list (acl-grant (quote u) (quote read) (quote p))))
true)
(acl-hd-check!
"validate: surfaces wrong-arity and unknown predicate"
(len
(acl-validate-facts
(list
(acl-grant (quote u) (quote read) (quote p))
(list (quote grant) (quote u))
(list (quote bogus) (quote x) (quote y)))))
2)
(acl-hd-check!
"validate: empty set is valid"
(acl-facts-valid? (list))
true))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(do
(acl-audit-clear!)
(acl-audit-decide! db (quote u) (quote read) (quote p))
(acl-audit-decide! db (quote u) (quote edit) (quote p))
(let
((snap (acl-audit-snapshot)))
(do
(acl-audit-clear!)
(acl-hd-check!
"audit: cleared count is 0"
(acl-audit-count)
0)
(acl-audit-restore! snap)
(acl-hd-check!
"audit: restored count"
(acl-audit-count)
2)
(acl-hd-check!
"audit: restored last act"
(get (first (acl-audit-tail 1)) :act)
(quote edit))
(acl-audit-decide! db (quote u) (quote comment) (quote p))
(acl-hd-check!
"audit: seq continues after restore"
(get (first (acl-audit-tail 1)) :seq)
2)
(acl-hd-check!
"audit: snapshot is an immutable copy"
(len (get snap :entries))
2)
(acl-audit-clear!))))))))
(define
acl-harden-tests-run!
(fn
()
(do
(set! acl-hd-pass 0)
(set! acl-hd-fail 0)
(set! acl-hd-failures (list))
(acl-hd-run-all!)
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))

202
lib/acl/tests/inherit.sx Normal file
View File

@@ -0,0 +1,202 @@
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
;; role expansion) with deny-overrides.
(define acl-it-pass 0)
(define acl-it-fail 0)
(define acl-it-failures (list))
(define
acl-it-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-it-pass (+ acl-it-pass 1))
(do
(set! acl-it-fail (+ acl-it-fail 1))
(append!
acl-it-failures
(str name "\n expected: " expected "\n got: " got))))))
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
(define
acl-it-run-all!
(fn
()
(do
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
(do
(acl-it-check!
"group grant reaches member"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"group grant: non-member excluded"
(acl-it-p? db (quote bob) (quote read) (quote doc))
false)
(acl-it-check!
"group grant: wrong action"
(acl-it-p? db (quote alice) (quote edit) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
(do
(acl-it-check!
"deep nested group grant reaches leaf member"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"intermediate group also covered"
(acl-it-p? db (quote team) (quote read) (quote doc))
true)
(acl-it-check!
"mid group org covered"
(acl-it-p? db (quote org) (quote read) (quote doc))
true)))
(let
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
(do
(acl-it-check!
"cyclic membership terminates and grants"
(acl-it-p? db (quote a) (quote read) (quote r))
true)
(acl-it-check!
"cyclic membership covers both"
(acl-it-p? db (quote b) (quote read) (quote r))
true)))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
(do
(acl-it-check!
"parent grant reaches direct child"
(acl-it-p? db (quote u) (quote read) (quote chap))
true)
(acl-it-check!
"parent grant reaches deep descendant"
(acl-it-p? db (quote u) (quote read) (quote sec))
true)
(acl-it-check!
"parent grant covers parent itself"
(acl-it-p? db (quote u) (quote read) (quote book))
true)
(acl-it-check!
"child grant does not climb to parent"
(acl-it-p?
(acl-build-db
(list
(acl-child-of (quote sec) (quote book))
(acl-grant (quote u) (quote read) (quote sec))))
(quote u)
(quote read)
(quote book))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
(do
(acl-it-check!
"group + resource: member on child resource"
(acl-it-p? db (quote alice) (quote comment) (quote post1))
true)
(acl-it-check!
"group + resource: member on parent resource"
(acl-it-p? db (quote alice) (quote comment) (quote board))
true)))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
(do
(acl-it-check!
"role confers edit to member"
(acl-it-p? db (quote bob) (quote edit) (quote page1))
true)
(acl-it-check!
"role confers read to member"
(acl-it-p? db (quote bob) (quote read) (quote page1))
true)
(acl-it-check!
"role: capability not in tuple denied"
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
false)
(acl-it-check!
"role: non-member excluded"
(acl-it-p? db (quote eve) (quote edit) (quote page1))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
(acl-it-check!
"role grant flows to child resource"
(acl-it-p? db (quote bob) (quote edit) (quote draft))
true))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-it-check!
"explicit deny beats inherited group allow"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
(do
(acl-it-check!
"group deny inherits and overrides direct grant"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false)
(acl-it-check!
"group deny: another member also blocked"
(acl-it-p? db (quote team) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
(acl-it-check!
"ancestor deny overrides descendant grant"
(acl-it-p? db (quote u) (quote read) (quote sec))
false))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
(do
(acl-it-check!
"deny on edit leaves inherited read intact"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"deny on edit blocks edit"
(acl-it-p? db (quote alice) (quote edit) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
(acl-it-check!
"inherited deny, no grant: denied"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
(do
(acl-it-check!
"deny on sibling a blocks a"
(acl-it-p? db (quote u) (quote read) (quote a))
false)
(acl-it-check!
"deny on sibling a leaves b permitted"
(acl-it-p? db (quote u) (quote read) (quote b))
true)
(acl-it-check!
"root itself still permitted"
(acl-it-p? db (quote u) (quote read) (quote root))
true)))
(let
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
(acl-it-check!
"direct grant under inheritance ruleset"
(acl-it-p? db (quote x) (quote read) (quote y))
true)))))
(define
acl-inherit-tests-run!
(fn
()
(do
(set! acl-it-pass 0)
(set! acl-it-fail 0)
(set! acl-it-failures (list))
(acl-it-run-all!)
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))

63
lib/apl/conformance.conf Normal file
View File

@@ -0,0 +1,63 @@
# APL conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=apl
MODE=counters
COUNTERS_PASS=apl-test-pass
COUNTERS_FAIL=apl-test-fail
TIMEOUT_PER_SUITE=300
PRELOADS=(
spec/stdlib.sx
lib/r7rs.sx
lib/apl/runtime.sx
lib/apl/tokenizer.sx
lib/apl/parser.sx
lib/apl/transpile.sx
lib/apl/test-harness.sx
)
SUITES=(
"structural:lib/apl/tests/structural.sx"
"operators:lib/apl/tests/operators.sx"
"dfn:lib/apl/tests/dfn.sx"
"tradfn:lib/apl/tests/tradfn.sx"
"valence:lib/apl/tests/valence.sx"
"programs:lib/apl/tests/programs.sx"
"system:lib/apl/tests/system.sx"
"idioms:lib/apl/tests/idioms.sx"
"eval-ops:lib/apl/tests/eval-ops.sx"
"pipeline:lib/apl/tests/pipeline.sx"
)
emit_scoreboard_json() {
local n=${#GC_NAMES[@]} i sep
printf '{\n'
printf ' "suites": {\n'
for ((i=0; i<n; i++)); do
sep=","; [ $i -eq $((n-1)) ] && sep=""
printf ' "%s": {"pass": %d, "fail": %d}%s\n' \
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "$sep"
done
printf ' },\n'
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
printf ' "total": %d\n' "$GC_TOTAL"
printf '}\n'
}
emit_scoreboard_md() {
local n=${#GC_NAMES[@]} i
printf '# APL Conformance Scoreboard\n\n'
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
printf '| Suite | Pass | Fail | Total |\n'
printf '|-------|-----:|-----:|------:|\n'
for ((i=0; i<n; i++)); do
printf '| %s | %d | %d | %d |\n' \
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "${GC_TOTAL_S[$i]}"
done
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
printf '\n'
printf '## Notes\n\n'
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
}

View File

@@ -1,116 +1,5 @@
#!/usr/bin/env bash
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
if [ ! -x "$SX_SERVER" ]; then
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
fi
if [ ! -x "$SX_SERVER" ]; then
echo "ERROR: sx_server.exe not found." >&2
exit 1
fi
SUITES=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
OUT_JSON="lib/apl/scoreboard.json"
OUT_MD="lib/apl/scoreboard.md"
run_suite() {
local suite=$1
local file="lib/apl/tests/${suite}.sx"
local TMP
TMP=$(mktemp)
cat > "$TMP" << EPOCHS
(epoch 1)
(load "spec/stdlib.sx")
(load "lib/r7rs.sx")
(load "lib/apl/runtime.sx")
(load "lib/apl/tokenizer.sx")
(load "lib/apl/parser.sx")
(load "lib/apl/transpile.sx")
(epoch 2)
(eval "(define apl-test-pass 0)")
(eval "(define apl-test-fail 0)")
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
(epoch 3)
(load "${file}")
(epoch 4)
(eval "(list apl-test-pass apl-test-fail)")
EPOCHS
local OUTPUT
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
rm -f "$TMP"
local LINE
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
if [ -z "$LINE" ]; then
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
| sed -E 's/^\(ok 4 //; s/\)$//')
fi
local P F
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
P=${P:-0}
F=${F:-0}
echo "${P} ${F}"
}
declare -A SUITE_PASS
declare -A SUITE_FAIL
TOTAL_PASS=0
TOTAL_FAIL=0
echo "Running APL conformance suite..." >&2
for s in "${SUITES[@]}"; do
read -r p f < <(run_suite "$s")
SUITE_PASS[$s]=$p
SUITE_FAIL[$s]=$f
TOTAL_PASS=$((TOTAL_PASS + p))
TOTAL_FAIL=$((TOTAL_FAIL + f))
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
done
# scoreboard.json
{
printf '{\n'
printf ' "suites": {\n'
first=1
for s in "${SUITES[@]}"; do
if [ $first -eq 0 ]; then printf ',\n'; fi
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
first=0
done
printf '\n },\n'
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
printf '}\n'
} > "$OUT_JSON"
# scoreboard.md
{
printf '# APL Conformance Scoreboard\n\n'
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
printf '| Suite | Pass | Fail | Total |\n'
printf '|-------|-----:|-----:|------:|\n'
for s in "${SUITES[@]}"; do
p=${SUITE_PASS[$s]}
f=${SUITE_FAIL[$s]}
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
done
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
printf '\n'
printf '## Notes\n\n'
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
} > "$OUT_MD"
echo "Wrote $OUT_JSON and $OUT_MD" >&2
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
[ "$TOTAL_FAIL" -eq 0 ]
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

View File

@@ -9,9 +9,9 @@
"system": {"pass": 13, "fail": 0},
"idioms": {"pass": 64, "fail": 0},
"eval-ops": {"pass": 14, "fail": 0},
"pipeline": {"pass": 40, "fail": 0}
"pipeline": {"pass": 152, "fail": 0}
},
"total_pass": 450,
"total_pass": 562,
"total_fail": 0,
"total": 450
"total": 562
}

View File

@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
| system | 13 | 0 | 13 |
| idioms | 64 | 0 | 64 |
| eval-ops | 14 | 0 | 14 |
| pipeline | 40 | 0 | 40 |
| **Total** | **450** | **0** | **450** |
| pipeline | 152 | 0 | 152 |
| **Total** | **562** | **0** | **562** |
## Notes

15
lib/apl/test-harness.sx Normal file
View File

@@ -0,0 +1,15 @@
; lib/apl/test-harness.sx — counters + assertion fn for the shared conformance
; driver (lib/guest/conformance.sh, MODE=counters). Loaded as a PRELOAD so each
; suite starts from a fresh 0/0; suites call (apl-test name got expected).
(define apl-test-pass 0)
(define apl-test-fail 0)
(define
apl-test
(fn
(name got expected)
(if
(= got expected)
(set! apl-test-pass (+ apl-test-pass 1))
(set! apl-test-fail (+ apl-test-fail 1)))))

329
lib/events/api.sx Normal file
View File

@@ -0,0 +1,329 @@
;; lib/events/api.sx — public events surface over calendar + availability.
;;
;; A `store` is an immutable value holding scheduled events and (in-memory)
;; bookings:
;;
;; {:events (event ...) :bookings ((actor key) ...)}
;;
;; The in-memory `:bookings` list supports pure, value-level queries. The
;; DURABLE booking path (ev/*-occ! and ev/*-p) keeps bookings in persist
;; streams via booking.sx — capacity-safe, cancellable, replayable — and
;; derives availability from those streams. Use the persist path for real
;; bookings; the in-memory path for projections and tests.
;;
;; All queries are windowed: agenda/free/next-free expand recurring events into
;; concrete occurrences within an explicit (or derived) window before running
;; the Datalog availability rules.
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
(define ev/empty (fn () (ev/store (list) (list))))
(define ev/events (fn (store) (get store :events)))
(define ev/bookings (fn (store) (get store :bookings)))
;; Add a (constructed) event to the store.
(define
ev/add-event
(fn
(store event)
(ev/store (cons event (ev/events store)) (ev/bookings store))))
;; Schedule a fresh event from parts, returning the updated store. rrule may be
;; nil for a one-off. (Booking is separate — see ev/book.)
(define
ev/schedule
(fn
(store id dtstart duration rrule capacity)
(ev/add-event store (ev-event id dtstart duration rrule capacity))))
;; Record that `actor` holds the occurrence with `key` (in-memory only — see
;; ev/book-occ! for the durable, capacity-safe path).
(define
ev/book
(fn
(store actor key)
(ev/store
(ev/events store)
(cons (list actor key) (ev/bookings store)))))
;; The event with `id`, or nil.
(define
ev/event-by-id
(fn
(store id)
(reduce
(fn
(found ev)
(if (nil? found) (if (= (get ev :id) id) ev found) found))
nil
(ev/events store))))
;; Capacity of the event an occurrence belongs to (0 if unknown).
(define
ev/capacity-of
(fn
(store occ)
(let
((ev (ev/event-by-id store (get occ :id))))
(if (nil? ev) 0 (get ev :capacity)))))
;; The maximum event duration in the store (0 when empty) — used to widen
;; expansion windows so any occurrence overlapping a query is captured.
(define
ev/store-max-duration
(fn
(store)
(reduce
(fn (m ev) (max m (get ev :duration)))
0
(ev/events store))))
;; All occurrences across all events within [ws, we), ascending by start.
(define
ev/agenda
(fn (store ws we) (ev-expand-all (ev/events store) ws we)))
(define
ev-key-member?
(fn
(k keys)
(cond
((empty? keys) false)
((= k (first keys)) true)
(else (ev-key-member? k (rest keys))))))
;; Occurrence keys `actor` has booked (in-memory store).
(define
ev/actor-keys
(fn
(store actor)
(reduce
(fn
(acc b)
(if (= (first b) actor) (cons (first (rest b)) acc) acc))
(list)
(ev/bookings store))))
;; The agenda restricted to occurrences `actor` is booked into (in-memory).
(define
ev/agenda-for
(fn
(store actor ws we)
(let
((keys (ev/actor-keys store actor)))
(filter
(fn (o) (ev-key-member? (ev-occ-key o) keys))
(ev/agenda store ws we)))))
;; Build an availability db over occurrences expanded in [ws, we) using the
;; in-memory bookings.
(define
ev/avail-window-db
(fn
(store ws we)
(ev-avail-db (ev/agenda store ws we) (ev/bookings store))))
;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the
;; longest event) to capture any occurrence that could overlap.
(define
ev/free?
(fn
(store actor qs qe)
(ev-free?
(ev/avail-window-db store (- qs (ev/store-max-duration store)) qe)
actor
qs
qe)))
;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil.
(define
ev/next-free
(fn
(store actor after duration horizon)
(ev-next-free
(ev/avail-window-db
store
(- after (ev/store-max-duration store))
horizon)
actor
after
duration
horizon)))
;; Overlapping double-bookings for `actor` among occurrences in [ws, we).
(define
ev/conflicts
(fn
(store actor ws we)
(ev-conflicts (ev/avail-window-db store ws we) actor)))
(define
ev/has-conflict?
(fn
(store actor ws we)
(> (len (ev/conflicts store actor ws we)) 0)))
;; ---- durable, persist-backed booking path ----
;; These take a persist backend `b` (persist/open) plus the schedule `store`.
;; Bookings live in per-occurrence streams (booking.sx); availability is derived
;; by replaying those streams for the occurrences in the query window.
;; Durably book `actor` into occurrence `occ` (dict {:id :start :end}),
;; capacity-safe. Returns the booking.sx result (:booked / :full / :already).
(define
ev/book-occ!
(fn
(b store actor occ)
(ev/book! b (ev-occ-key occ) (ev/capacity-of store occ) actor)))
;; Durably cancel `actor`'s seat on `occ`, freeing capacity.
(define
ev/cancel-occ!
(fn (b store actor occ) (ev/cancel! b (ev-occ-key occ) actor)))
;; Live roster / seats-left for a specific occurrence from persist.
(define ev/roster-occ (fn (b occ) (ev/roster b (ev-occ-key occ))))
(define
ev/seats-left-occ
(fn
(b store occ)
(ev/seats-left b (ev-occ-key occ) (ev/capacity-of store occ))))
;; Derive (actor key) booking pairs from the persist rosters of `occs`.
(define
ev/persist-bookings
(fn
(b occs)
(reduce
(fn
(acc occ)
(let
((key (ev-occ-key occ)))
(append
acc
(map (fn (actor) (list actor key)) (ev/roster b key)))))
(list)
occs)))
;; Availability db over [ws, we) with bookings sourced from persist streams.
(define
ev/avail-db-p
(fn
(b store ws we)
(let
((occs (ev/agenda store ws we)))
(ev-avail-db occs (ev/persist-bookings b occs)))))
;; Persist-backed availability queries (mirror the in-memory ev/free? etc).
(define
ev/free-p?
(fn
(b store actor qs qe)
(ev-free?
(ev/avail-db-p b store (- qs (ev/store-max-duration store)) qe)
actor
qs
qe)))
(define
ev/next-free-p
(fn
(b store actor after duration horizon)
(ev-next-free
(ev/avail-db-p b store (- after (ev/store-max-duration store)) horizon)
actor
after
duration
horizon)))
(define
ev/conflicts-p
(fn
(b store actor ws we)
(ev-conflicts (ev/avail-db-p b store ws we) actor)))
(define
ev/has-conflict-p?
(fn
(b store actor ws we)
(> (len (ev/conflicts-p b store actor ws we)) 0)))
;; ---- conflict-checked booking ----
;; Capacity is per-event, but an attendee should not be double-booked against
;; THEMSELVES across different events. Would booking `actor` into `occ` overlap
;; an existing booking of theirs elsewhere? (Derived from persist availability;
;; an existing booking into `occ` itself is excluded — that's idempotent.)
(define
ev/would-time-conflict?
(fn
(b store actor occ)
(and
(not (ev-actor-booked? b (ev-occ-key occ) actor))
(not (ev/free-p? b store actor (get occ :start) (get occ :end))))))
;; Book `actor` into `occ` only if it doesn't clash with their other bookings.
;; Re-booking the same occurrence is idempotent (:already); a clash returns
;; :time-conflict; otherwise the normal ev/book-occ! result (:booked / :full).
(define
ev/book-checked!
(fn
(b store actor occ)
(cond
((ev-actor-booked? b (ev-occ-key occ) actor) (ev/book-occ! b store actor occ))
((ev/would-time-conflict? b store actor occ)
{:status :time-conflict :actor actor :occ-key (ev-occ-key occ)})
(else (ev/book-occ! b store actor occ)))))
;; ---- whole-series operations ----
;; Apply a booking action to every occurrence of one event in [ws, we) — e.g.
;; "RSVP to the whole weekly class". Returns a list of (occ-key status) results,
;; one per occurrence (empty if the event id is unknown).
(define
ev/book-series!
(fn
(b store actor event-id ws we)
(let
((ev (ev/event-by-id store event-id)))
(if
(nil? ev)
(list)
(map
(fn (occ) (list (ev-occ-key occ) (get (ev/book-occ! b store actor occ) :status)))
(ev-expand ev ws we))))))
;; Cancel `actor` from every occurrence of one event in [ws, we).
(define
ev/cancel-series!
(fn
(b store actor event-id ws we)
(let
((ev (ev/event-by-id store event-id)))
(if
(nil? ev)
(list)
(map
(fn (occ) (list (ev-occ-key occ) (get (ev/cancel! b (ev-occ-key occ) actor) :status)))
(ev-expand ev ws we))))))
;; How many statuses in a series-result list equal `status`.
(define
ev/series-count
(fn
(results status)
(len (filter (fn (r) (= (first (rest r)) status)) results))))
;; The occurrences of one event in [ws, we) that `actor` is booked into.
(define
ev/series-booked
(fn
(b store actor event-id ws we)
(let
((ev (ev/event-by-id store event-id)))
(if
(nil? ev)
(list)
(filter
(fn (occ) (ev-actor-booked? b (ev-occ-key occ) actor))
(ev-expand ev ws we))))))

177
lib/events/availability.sx Normal file
View File

@@ -0,0 +1,177 @@
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
;;
;; Availability is per-actor and is forward-chained Datalog over two EDB
;; relations:
;;
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
;; (booking Actor Key) ; actor attends/holds that occurrence
;;
;; The derived relations are the whole policy:
;;
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
;;
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
;; next free?" (ev-next-free probes candidate slots with the same rule).
;; A stable key for an occurrence dict {:id :start :end}.
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
(define
ev-occurrence-fact
(fn
(occ)
(list
(quote occurrence)
(ev-occ-key occ)
(get occ :id)
(get occ :start)
(get occ :end))))
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
;; Range restriction: each comparison's variables are bound by an earlier
;; positive literal (qwindow / busy precede the < tests). Conflict uses
;; (< O1 O2) on the keys so each overlapping pair is reported once.
(define
ev-avail-rules
(quote
((busy A S E <- (booking A O) (occurrence O _ S E))
(conflict
A
O1
O2
<-
(booking A O1)
(booking A O2)
(occurrence O1 _ S1 E1)
(occurrence O2 _ S2 E2)
(< O1 O2)
(< S1 E2)
(< S2 E1))
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
;; Build a Datalog db from EDB facts under the availability ruleset.
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
;; Convenience: build a db from occurrence dicts + booking pairs.
;; bookings is a list of (actor key) pairs.
(define
ev-avail-db
(fn
(occs bookings)
(ev-build-avail
(append
(ev-occurrence-facts occs)
(map
(fn (b) (ev-booking-fact (first b) (first (rest b))))
bookings)))))
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
(define
ev-list-before?
(fn
(a b)
(cond
((< (first a) (first b)) true)
((> (first a) (first b)) false)
(else (< (first (rest a)) (first (rest b)))))))
(define
ev-list-insert
(fn
(x sorted)
(cond
((empty? sorted) (list x))
((ev-list-before? x (first sorted)) (cons x sorted))
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
(define
ev-sort-lists
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
(define
ev-dedup-sorted
(fn
(xs)
(cond
((empty? xs) xs)
((empty? (rest xs)) xs)
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
;; All busy intervals (list S E) for an actor, ascending by start.
(define
ev-busy
(fn
(db actor)
(let
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
(define
ev-conflicts
(fn
(db actor)
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
(define
ev-has-conflict?
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
(define
ev-free?
(fn
(db actor qs qe)
(do
(dl-assert! db (ev-qwindow-fact qs qe))
(let
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
;; ---- next-free slot search ----
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
;; such slot must begin either at `after` or immediately after some busy
;; interval ends (classic interval packing), so those are the only candidates
;; we probe — each probe reuses the busy_in rule via ev-free?.
(define
ev-first-free
(fn
(db actor cands duration horizon)
(cond
((empty? cands) nil)
(else
(let
((s (first cands)))
(if
(and
(<= (+ s duration) horizon)
(ev-free? db actor s (+ s duration)))
s
(ev-first-free db actor (rest cands) duration horizon)))))))
(define
ev-next-free
(fn
(db actor after duration horizon)
(let
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
(ev-first-free
db
actor
(ev-dedup-sorted (sort (cons after ends)))
duration
horizon))))

View File

@@ -0,0 +1,102 @@
;; lib/events/booking-notify.sx — derive lifecycle notifications from the
;; booking stream, for delivery via notify.sx.
;;
;; Walking the append-only booking stream yields one notification per state
;; change, in order, classified by kind:
;;
;; :booked a confirmed booking
;; :promoted a booking for an actor who was on the waitlist (auto-promote)
;; :held a provisional hold (pending payment)
;; :confirmed a held seat became confirmed (payment succeeded)
;; :released a held seat was released (payment failed/expired)
;; :cancelled a seat was given up
;; :waitlisted an actor joined the waitlist
;;
;; Promotion is detected by folding the waitlist as we walk: a :booking for an
;; actor currently on the waitlist is a promotion, not a fresh booking.
;;
;; Each notification's id is occ-key/seq (the stream seq is unique and stable),
;; so re-deriving and re-delivering is idempotent — the notify transport dedups
;; on this id and never double-pings.
(define
ev-bn-kind
(fn
(typ promoted?)
(cond
((= typ :hold) :held)
((= typ :booking) (if promoted? :promoted :booked))
((= typ :confirm) :confirmed)
((= typ :cancel) :cancelled)
((= typ :release) :released)
((= typ :waitlist) :waitlisted)
(else nil))))
(define
ev-bn-update-waiting
(fn
(typ actor waiting)
(cond
((= typ :waitlist)
(if
(ev-bk-member? actor waiting)
waiting
(ev-bk-append waiting actor)))
((= typ :unwaitlist) (ev-bk-remove waiting actor))
((= typ :booking) (ev-bk-remove waiting actor))
((= typ :hold) (ev-bk-remove waiting actor))
(else waiting))))
(define ev-bn-mk (fn (occ-key label actor kind seq) {:id (str occ-key "/" seq) :event label :kind kind :recipient actor :seq seq}))
(define
ev-bn-step
(fn
(occ-key label events waiting)
(if
(empty? events)
(list)
(let
((e (first events)))
(let
((typ (persist/event-type e))
(actor (get (persist/event-data e) :actor))
(seq (persist/event-seq e)))
(let
((promoted? (and (= typ :booking) (ev-bk-member? actor waiting))))
(let
((kind (ev-bn-kind typ promoted?))
(waiting2 (ev-bn-update-waiting typ actor waiting)))
(if
(nil? kind)
(ev-bn-step occ-key label (rest events) waiting2)
(cons
(ev-bn-mk occ-key label actor kind seq)
(ev-bn-step occ-key label (rest events) waiting2))))))))))
;; The ordered lifecycle notifications for an occurrence's bookings. `label` is
;; a human-facing event id carried on each notification.
(define
ev/booking-notifications
(fn
(b occ-key label)
(ev-bn-step
occ-key
label
(persist/read b (ev-booking-stream occ-key))
(list))))
;; Filter notifications to a single kind.
(define
ev/notify-of-kind
(fn (notifs kind) (filter (fn (n) (= (get n :kind) kind)) notifs)))
;; Project a notification to notify.sx's (id recipient body) wire shape.
(define
ev/booking-notify->msg
(fn
(n)
(list
(get n :id)
(get n :recipient)
(list :booking-event (get n :kind) (get n :event)))))

372
lib/events/booking.sx Normal file
View File

@@ -0,0 +1,372 @@
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
;;
;; Each bookable occurrence has an append-only stream of booking events:
;;
;; :booking free booking — actor immediately holds a confirmed seat
;; :hold provisional hold — seat reserved while payment is pending
;; :confirm a held seat becomes confirmed (payment succeeded)
;; :release a held seat is abandoned (payment failed/expired) — seat freed
;; :cancel a held or confirmed seat is given up — seat freed
;;
;; The live state is the stream FOLDED in order into per-actor seat states
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
;; confirmed seats count toward capacity — a pending payment cannot be
;; oversold. A freed seat (release/cancel) reopens capacity.
;;
;; Capacity safety is the contract: two writers racing for the last seat must
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
;; persist's optimistic concurrency — `persist/append-expect` appends only if
;; the stream's last-seq still equals what the writer observed; else it returns
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
;; the state transition (:confirm) never oversell, so they append directly.
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
(define
ev-bk-member?
(fn
(x xs)
(cond
((empty? xs) false)
((= x (first xs)) true)
(else (ev-bk-member? x (rest xs))))))
(define
ev-bk-index
(fn
(xs x i)
(cond
((empty? xs) -1)
((= (first xs) x) i)
(else (ev-bk-index (rest xs) x (+ i 1))))))
(define ev-bk-append (fn (xs a) (append xs (list a))))
(define ev-bk-remove (fn (xs a) (filter (fn (x) (not (= x a))) xs)))
;; ---- per-actor state association list: ((actor state) ...) in join order ----
(define
ev-state-has?
(fn
(states actor)
(cond
((empty? states) false)
((= (first (first states)) actor) true)
(else (ev-state-has? (rest states) actor)))))
(define
ev-state-get
(fn
(states actor)
(cond
((empty? states) :none)
((= (first (first states)) actor) (first (rest (first states))))
(else (ev-state-get (rest states) actor)))))
(define
ev-state-del
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
(define
ev-state-set
(fn
(states actor st)
(if
(ev-state-has? states actor)
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
(append states (list (list actor st))))))
;; Fold the booking stream into per-actor seat states (join order preserved).
(define
ev-fold-states
(fn
(events)
(reduce
(fn
(acc e)
(let
((typ (persist/event-type e))
(actor (get (persist/event-data e) :actor)))
(cond
((= typ :booking) (ev-state-set acc actor :confirmed))
((= typ :hold) (ev-state-set acc actor :held))
((= typ :confirm)
(if
(ev-state-has? acc actor)
(ev-state-set acc actor :confirmed)
acc))
((= typ :cancel) (ev-state-del acc actor))
((= typ :release) (ev-state-del acc actor))
(else acc))))
(list)
events)))
(define
ev-states-of
(fn
(b occ-key)
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
(define
ev-booked-actors
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
(define
ev-actor-booked?
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
;; Live seat count (folded roster size — both held and confirmed seats).
(define
ev-booking-count
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
;; Seat state for an actor: :held / :confirmed / :none.
(define
ev/seat-state
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
;; 1-based seat number for an actor on the roster (0 if not booked).
(define
ev-seat-of
(fn
(actors actor)
(let
((i (ev-bk-index actors actor 0)))
(if (< i 0) 0 (+ i 1)))))
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
;; concurrent append landed since the snapshot — the caller must re-observe.
(define
ev-acquire-with-observed
(fn
(b occ-key capacity actor observed-actors expected kind ok-status)
(cond
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
(else
(let
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
(define
ev-acquire!
(fn
(b occ-key capacity actor kind ok-status)
(let
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
(if
(= (get res :status) :conflict)
(ev-acquire! b occ-key capacity actor kind ok-status)
res))))
;; Capacity-safe confirmed booking (retrying on conflict).
(define
ev/book!
(fn
(b occ-key capacity actor)
(ev-acquire! b occ-key capacity actor :booking :booked)))
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
;; (counts toward capacity) until confirmed or released.
(define
ev/hold!
(fn
(b occ-key capacity actor)
(ev-acquire! b occ-key capacity actor :hold :held)))
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
(define
ev/book-with-observed
(fn
(b occ-key capacity actor observed-actors expected)
(ev-acquire-with-observed
b
occ-key
capacity
actor
observed-actors
expected
:booking :booked)))
(define
ev/hold-with-observed
(fn
(b occ-key capacity actor observed-actors expected)
(ev-acquire-with-observed
b
occ-key
capacity
actor
observed-actors
expected
:hold :held)))
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
;; Confirm a held seat (payment succeeded). :confirmed on success,
;; :already-confirmed if it was confirmed, :not-held otherwise.
(define
ev/confirm!
(fn
(b occ-key actor)
(let
((st (ev/seat-state b occ-key actor)))
(cond
((= st :held)
(begin
(persist/append
b
(ev-booking-stream occ-key)
:confirm 0
{:actor actor})
{:actor actor :status :confirmed}))
((= st :confirmed) {:actor actor :status :already-confirmed})
(else {:actor actor :status :not-held})))))
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
;; held seat — confirmed bookings are given up via ev/cancel!.
(define
ev/release!
(fn
(b occ-key actor)
(let
((st (ev/seat-state b occ-key actor)))
(if
(= st :held)
(begin
(persist/append
b
(ev-booking-stream occ-key)
:release 0
{:actor actor})
{:actor actor :status :released})
{:actor actor :status :not-held}))))
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
(define
ev/cancel!
(fn
(b occ-key actor)
(if
(ev-bk-member? actor (ev-booked-actors b occ-key))
(begin
(persist/append
b
(ev-booking-stream occ-key)
:cancel 0
{:actor actor})
{:actor actor :status :cancelled})
{:actor actor :status :not-booked})))
;; The roster as a plain list of actors (oldest active first).
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
;; Seats remaining for an occurrence of the given capacity.
(define
ev/seats-left
(fn
(b occ-key capacity)
(max 0 (- capacity (ev-booking-count b occ-key)))))
;; ---- waitlist ----
;; When an occurrence is full, actors join a FIFO waitlist (:waitlist /
;; :unwaitlist events on the same stream). Taking a seat (:booking / :hold)
;; removes an actor from the queue, so the waitlist fold is independent of the
;; seat fold. Cancelling/releasing a seat can auto-promote the head of the
;; queue (a :booking appended for them).
(define
ev-fold-waiting
(fn
(events)
(reduce
(fn
(acc e)
(let
((typ (persist/event-type e))
(actor (get (persist/event-data e) :actor)))
(cond
((= typ :waitlist) (if (ev-bk-member? actor acc) acc (ev-bk-append acc actor)))
((= typ :unwaitlist) (ev-bk-remove acc actor))
((= typ :booking) (ev-bk-remove acc actor))
((= typ :hold) (ev-bk-remove acc actor))
(else acc))))
(list)
events)))
;; The current waitlist queue (FIFO, oldest first).
(define
ev/waitlist
(fn (b occ-key) (ev-fold-waiting (persist/read b (ev-booking-stream occ-key)))))
;; 1-based queue position for an actor (0 if not waiting).
(define
ev/waitlist-position
(fn (b occ-key actor) (ev-seat-of (ev/waitlist b occ-key) actor)))
;; Book if a seat is free, else join the waitlist. Idempotent: already seated →
;; :already; already queued → :already-waiting.
(define
ev/waitlist!
(fn
(b occ-key capacity actor)
(let
((seats (ev-booked-actors b occ-key))
(waiting (ev/waitlist b occ-key)))
(cond
((ev-bk-member? actor seats)
{:status :already :seat (ev-seat-of seats actor) :actor actor})
((ev-bk-member? actor waiting)
{:status :already-waiting :position (ev-seat-of waiting actor) :actor actor})
(else
(let
((r (ev/book! b occ-key capacity actor)))
(if
(= (get r :status) :booked)
r
(begin
(persist/append b (ev-booking-stream occ-key) :waitlist 0 {:actor actor})
{:status :waitlisted
:position (+ (len waiting) 1)
:actor actor}))))))))
;; Leave the waitlist. :left or :not-waiting.
(define
ev/leave-waitlist!
(fn
(b occ-key actor)
(if
(ev-bk-member? actor (ev/waitlist b occ-key))
(begin
(persist/append b (ev-booking-stream occ-key) :unwaitlist 0 {:actor actor})
{:status :left :actor actor})
{:status :not-waiting :actor actor})))
;; Cancel a seat and, if that frees capacity, auto-promote the head of the
;; waitlist (a confirmed booking). Returns the cancel result plus :promoted
;; (the actor promoted, or nil).
(define
ev/cancel-promote!
(fn
(b occ-key capacity actor)
(let
((c (ev/cancel! b occ-key actor)))
(if
(= (get c :status) :cancelled)
(let
((waiting (ev/waitlist b occ-key))
(seats (ev-booked-actors b occ-key)))
(if
(and (not (empty? waiting)) (< (len seats) capacity))
(let
((promoted (first waiting)))
(begin
(persist/append b (ev-booking-stream occ-key) :booking 0 {:actor promoted})
{:status :cancelled :actor actor :promoted promoted}))
{:status :cancelled :actor actor :promoted nil}))
c))))

614
lib/events/calendar.sx Normal file
View File

@@ -0,0 +1,614 @@
;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
;;
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
;; minute-of-day. Ordering, window bounds, and durations are plain integer
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
;;
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
;; infinite computation and is never permitted. Supported subset (RFC 5545):
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
;; negative = from month end). YEARLY and the rest are deferred.
;; ---- integer helpers ----
;; Floored integer division (modulo is already floored, so the remainder
;; subtraction makes the quotient exact and floor-correct for any sign).
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))
(define ev-or (fn (x d) (if (nil? x) d x)))
(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))
;; ---- civil date core (Hinnant) ----
;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
(define
ev-days-from-civil
(fn
(y0 m d)
(let
((y (if (<= m 2) (- y0 1) y0)))
(let
((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
(let
((yoe (- y (* era 400)))
(doy
(+
(ev-floor-div
(+
(*
153
(+ m (if (> m 2) -3 9)))
2)
5)
(- d 1))))
(let
((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
(+ (* era 146097) doe -719468)))))))
;; Civil (y m d) list from a day-number.
(define
ev-civil-from-days
(fn
(z0)
(let
((z (+ z0 719468)))
(let
((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
(let
((doe (- z (* era 146097))))
(let
((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
(let
((y (+ yoe (* era 400)))
(doy
(-
doe
(+
(* 365 yoe)
(ev-floor-div yoe 4)
(- (ev-floor-div yoe 100))))))
(let
((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
(let
((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
(m
(if
(< mp 10)
(+ mp 3)
(- mp 9))))
(list (if (<= m 2) (+ y 1) y) m d))))))))))
;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))
(define
ev-days-in-month
(fn
(y m)
(-
(ev-days-from-civil
(if (= m 12) (+ y 1) y)
(if (= m 12) 1 (+ m 1))
1)
(ev-days-from-civil y m 1))))
;; Add k months to (y,m), returning (list y2 m2).
(define
ev-add-months
(fn
(y m k)
(let
((total (+ (* y 12) (- m 1) k)))
(list
(ev-floor-div total 12)
(+ (modulo total 12) 1)))))
;; ---- datetime (epoch minutes) ----
(define
ev-dt
(fn
(y m d hh mm)
(+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))
(define ev-date (fn (y m d) (ev-dt y m d 0 0)))
(define ev-dt->days (fn (t) (ev-floor-div t 1440)))
(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))
(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))
(define ev-dt-tod (fn (t) (modulo t 1440)))
(define ev-civ-y (fn (c) (first c)))
(define ev-civ-m (fn (c) (first (rest c))))
(define ev-civ-d (fn (c) (first (rest (rest c)))))
;; ---- event + occurrence constructors ----
;; rrule is nil (single event) or a dict:
;; {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
;; :byday ...|nil :bymonthday (list 15 -1)|nil}
;; weekly :byday -> (list 0 2 4) weekday numbers, 0=Mon
;; monthly :byday -> (list {:ord 2 :wd 1}) nth weekday (ord<0 from end)
;; monthly :bymonthday -> (list 15 -1) day of month (negative from end)
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))
;; Event with EXDATE/RDATE exceptions. exdate/rdate are lists of epoch-minute
;; starts to exclude from / add to the expansion (RFC 5545 VEVENT properties).
(define
ev-event-full
(fn
(id dtstart duration rrule capacity exdate rdate)
{:duration duration
:id id
:dtstart dtstart
:capacity capacity
:rrule rrule
:exdate exdate
:rdate rdate}))
(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))
;; ---- DAILY expansion ----
;; occ starts at dtstart; n counts every generated occurrence (window-
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
;; occurrences inside [win-start, win-end].
(define
ev-daily-loop
(fn
(id occ duration step count until dtstart win-start win-end acc n)
(cond
((> occ win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
((and (not (nil? until)) (> occ until)) acc)
(else
(begin
(when (>= occ win-start) (append! acc (ev-occ id occ duration)))
(ev-daily-loop
id
(+ occ step)
duration
step
count
until
dtstart
win-start
win-end
acc
(+ n 1)))))))
;; ---- shared per-period emit ----
;; Walk a start-ascending list of candidate occurrence datetimes for one
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
;; emitting those also inside the window. Returns the updated running n.
(define
ev-emit-occs
(fn
(id occs duration count until dtstart win-start win-end acc n)
(if
(empty? occs)
n
(let
((occ (first occs)))
(let
((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
(begin
(when
(and generates? (>= occ win-start) (<= occ win-end))
(append! acc (ev-occ id occ duration)))
(ev-emit-occs
id
(rest occs)
duration
count
until
dtstart
win-start
win-end
acc
(if generates? (+ n 1) n))))))))
;; ---- WEEKLY expansion ----
;; Iterate week by week from the Monday of dtstart's week; within each active
;; week emit each BYDAY (sorted). n counts every generated occurrence.
(define
ev-week0-days
(fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))
(define
ev-byday-default
(fn
(byday dtstart)
(if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))
(define
ev-weekly-loop
(fn
(id
week-days
tod
duration
week-step
bd
count
until
dtstart
win-start
win-end
acc
n)
(let
((week-start-dt (* week-days 1440)))
(cond
((> week-start-dt win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
(else
(let
((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
(let
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
(ev-weekly-loop
id
(+ week-days week-step)
tod
duration
week-step
bd
count
until
dtstart
win-start
win-end
acc
n2))))))))
;; ---- MONTHLY expansion ----
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
;; day-of-month of dtstart (skipped in months too short to contain it).
;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
(define
ev-resolve-monthday
(fn
(y m bmd)
(let
((dim (ev-days-in-month y m)))
(let
((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
(if (and (>= day 1) (<= day dim)) day nil)))))
;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
(define
ev-resolve-nth-weekday
(fn
(y m ord wd)
(let
((dim (ev-days-in-month y m)))
(if
(> ord 0)
(let
((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
(let
((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
(if (<= day dim) day nil)))
(let
((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
(let
((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
(if (>= day 1) day nil)))))))
(define
ev-month-candidates
(fn
(y m rrule dtstart)
(let
((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
(cond
((not (nil? bmd))
(ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
((not (nil? byday))
(ev-filter-nil
(map
(fn
(e)
(ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
byday)))
(else
(ev-filter-nil
(list
(ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))
(define
ev-monthly-loop
(fn
(id
y
m
rrule
duration
tod
interval
count
until
dtstart
win-start
win-end
acc
n)
(let
((month-start (ev-dt y m 1 0 0)))
(cond
((> month-start win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
(else
(let
((days (sort (ev-month-candidates y m rrule dtstart))))
(let
((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
(let
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
(nm (ev-add-months y m interval)))
(ev-monthly-loop
id
(ev-civ-y nm)
(ev-civ-m nm)
rrule
duration
tod
interval
count
until
dtstart
win-start
win-end
acc
n2)))))))))
;; ---- top-level expansion ----
;; Raw expansion (RRULE / single event), before EXDATE/RDATE are applied.
;; Returns a list of occurrence dicts {:id :start :end} within the window.
(define
ev-expand-base
(fn
(event win-start win-end)
(let
((id (get event :id))
(dtstart (get event :dtstart))
(duration (get event :duration))
(rrule (get event :rrule)))
(if
(nil? rrule)
(if
(and (>= dtstart win-start) (<= dtstart win-end))
(list (ev-occ id dtstart duration))
(list))
(let
((freq (get rrule :freq))
(interval (ev-or (get rrule :interval) 1))
(count (get rrule :count))
(until (get rrule :until))
(byday (get rrule :byday))
(acc (list)))
(begin
(cond
((= freq :daily)
(ev-daily-loop
id
dtstart
duration
(* interval 1440)
count
until
dtstart
win-start
win-end
acc
0))
((= freq :weekly)
(ev-weekly-loop
id
(ev-week0-days dtstart)
(ev-dt-tod dtstart)
duration
(* interval 7)
(ev-byday-default byday dtstart)
count
until
dtstart
win-start
win-end
acc
0))
((= freq :monthly)
(let
((civ (ev-dt->civil dtstart)))
(ev-monthly-loop
id
(ev-civ-y civ)
(ev-civ-m civ)
rrule
duration
(ev-dt-tod dtstart)
interval
count
until
dtstart
win-start
win-end
acc
0)))
(else (error (str "ev-expand-base: unsupported freq: " freq))))
acc))))))
;; ---- EXDATE / RDATE (RFC 5545 exceptions) ----
;; Applied AFTER raw expansion: RDATE adds explicit occurrences within the
;; window, EXDATE removes occurrences whose start matches (EXDATE wins over
;; RDATE). Both are VEVENT-level: (get event :exdate) / (get event :rdate) are
;; lists of epoch-minute starts; nil for plain events.
(define
ev-num-member?
(fn
(n xs)
(cond
((empty? xs) false)
((= n (first xs)) true)
(else (ev-num-member? n (rest xs))))))
;; Drop duplicate-start occurrences from a start-sorted list (keep one).
(define
ev-dedupe-by-start
(fn
(occs)
(cond
((empty? occs) occs)
((empty? (rest occs)) occs)
((= (get (first occs) :start) (get (first (rest occs)) :start))
(ev-dedupe-by-start (rest occs)))
(else (cons (first occs) (ev-dedupe-by-start (rest occs)))))))
(define
ev-apply-exceptions
(fn
(event base win-start win-end)
(let
((id (get event :id))
(duration (get event :duration))
(exdate (ev-or (get event :exdate) (list)))
(rdate (ev-or (get event :rdate) (list))))
(let
((rdate-occs
(reduce
(fn
(acc d)
(if
(and (>= d win-start) (<= d win-end))
(cons (ev-occ id d duration) acc)
acc))
(list)
rdate)))
(let
((no-ex
(filter
(fn (o) (not (ev-num-member? (get o :start) exdate)))
(append base rdate-occs))))
(ev-dedupe-by-start (ev-sort-occs no-ex)))))))
;; ---- per-occurrence overrides (RFC 5545 RECURRENCE-ID) ----
;; A single instance of a recurring series can be detached and rescheduled. The
;; event carries :overrides — a list of (orig-start {:start :duration}) — keyed
;; by the occurrence's ORIGINAL start. Applied after EXDATE/RDATE. A moved
;; instance whose new start leaves the window is dropped from this window (the
;; original slot is vacated); an instance moved INTO the window from outside is
;; out of scope for a windowed expansion (known stub limitation).
(define
ev-assoc-lookup
(fn
(k pairs)
(cond
((empty? pairs) nil)
((= (first (first pairs)) k) (first (rest (first pairs))))
(else (ev-assoc-lookup k (rest pairs))))))
(define
ev-apply-overrides
(fn
(id base overrides)
(map
(fn
(o)
(let
((ov (ev-assoc-lookup (get o :start) overrides)))
(if (nil? ov) o (ev-occ id (get ov :start) (get ov :duration)))))
base)))
;; Add an override that reschedules the occurrence originally at `orig-start`
;; to `new-start` with `new-duration`.
(define
ev-with-override
(fn
(event orig-start new-start new-duration)
(assoc
event
:overrides
(cons
(list orig-start {:start new-start :duration new-duration})
(ev-or (get event :overrides) (list))))))
;; Naive (single time-domain) expansion: RRULE + EXDATE/RDATE + overrides.
(define
ev-expand-naive
(fn
(event win-start win-end)
(let
((excepted
(ev-apply-exceptions
event
(ev-expand-base event win-start win-end)
win-start
win-end))
(overrides (ev-or (get event :overrides) (list)))
(id (get event :id)))
(if
(empty? overrides)
excepted
(filter
(fn (o) (and (>= (get o :start) win-start) (<= (get o :start) win-end)))
(ev-sort-occs (ev-apply-overrides id excepted overrides)))))))
;; Public entry point. A tz-aware event (`:tz` set) expands in local wall-clock
;; time and converts each occurrence to UTC (ev-expand-tz, timezone.sx); a plain
;; event expands naively in a single time domain. The window is UTC either way.
(define
ev-expand
(fn
(event win-start win-end)
(let
((tz (get event :tz)))
(if
(nil? tz)
(ev-expand-naive event win-start win-end)
(ev-expand-tz event tz win-start win-end)))))
;; ---- multi-event expansion (sorted by start) ----
;; Insertion of one occurrence into a start-ascending list.
(define
ev-occ-insert
(fn
(o sorted)
(cond
((empty? sorted) (list o))
((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
(else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))
(define
ev-sort-occs
(fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))
;; Expand many events into one occurrence list, ascending by start.
(define
ev-expand-all
(fn
(events win-start win-end)
(let
((acc (list)))
(begin
(for-each
(fn
(ev)
(for-each
(fn (o) (append! acc o))
(ev-expand ev win-start win-end)))
events)
(ev-sort-occs acc)))))

View File

@@ -0,0 +1,63 @@
# events-on-sx conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=events
MODE=dict
SCOREBOARD_DIR=lib/events
PRELOADS=(
spec/stdlib.sx
lib/r7rs.sx
lib/datalog/tokenizer.sx
lib/datalog/parser.sx
lib/datalog/unify.sx
lib/datalog/db.sx
lib/datalog/builtins.sx
lib/datalog/aggregates.sx
lib/datalog/strata.sx
lib/datalog/eval.sx
lib/datalog/api.sx
lib/datalog/magic.sx
lib/events/calendar.sx
lib/events/timezone.sx
lib/events/ical.sx
lib/events/availability.sx
lib/persist/event.sx
lib/persist/backend.sx
lib/persist/log.sx
lib/persist/kv.sx
lib/persist/concurrency.sx
lib/persist/api.sx
lib/events/booking.sx
lib/events/booking-notify.sx
lib/events/ticket.sx
lib/guest/lex.sx
lib/guest/reflective/env.sx
lib/guest/reflective/quoting.sx
lib/scheme/parser.sx
lib/scheme/eval.sx
lib/scheme/runtime.sx
lib/flow/spec.sx
lib/flow/store.sx
lib/flow/remote.sx
lib/flow/host.sx
lib/flow/api.sx
lib/events/notify.sx
lib/events/api.sx
lib/events/reminders.sx
lib/events/federation.sx
)
SUITES=(
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
"timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)"
"ical:lib/events/tests/ical.sx:(ev-ical-tests-run!)"
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
"booking-notify:lib/events/tests/booking-notify.sx:(ev-booking-notify-tests-run!)"
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
"federation:lib/events/tests/federation.sx:(ev-federation-tests-run!)"
"integration:lib/events/tests/integration.sx:(ev-integration-tests-run!)"
)

3
lib/events/conformance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Thin wrapper — see lib/guest/conformance.sh and lib/events/conformance.conf.
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

232
lib/events/federation.sx Normal file
View File

@@ -0,0 +1,232 @@
;; lib/events/federation.sx — cross-instance calendar federation (trust-gated).
;;
;; A peer is another events instance that publishes a schedule (an events
;; store). We merge a peer's agenda into ours ONLY if we trust it — trust is a
;; set of peer ids, re-checked on every merge, so revoking a peer takes effect
;; immediately. Merged occurrences carry :origin provenance (:local for ours, or
;; the peer id) so a consumer always knows where a slot came from.
;;
;; This is the trust-gated stub: peers publish plain schedules and we fold the
;; trusted ones into a single sorted agenda. Real transport (fed-sx / signed
;; fetch) slots in behind `ev/peer-agenda` without changing the merge.
;;
;; Federated FREE/BUSY follows the iCal model: a peer publishes BUSY intervals
;; for an actor (not event details — privacy-preserving), and we union local +
;; trusted-peer busy to answer "is this actor free?" across instances.
(define ev/peer (fn (id store) {:id id :busy (list) :store store}))
;; A peer that also publishes free/busy: `busy` is a list of
;; (actor ((start end) ...)) pairs.
(define ev/peer-with-busy (fn (id store busy) {:id id :busy busy :store store}))
(define ev/peer-id (fn (p) (get p :id)))
(define ev/peer-store (fn (p) (get p :store)))
(define ev/peer-busy-table (fn (p) (get p :busy)))
(define
ev-fed-member?
(fn
(x xs)
(cond
((empty? xs) false)
((= x (first xs)) true)
(else (ev-fed-member? x (rest xs))))))
;; Do we trust this peer id? (trust is a list of trusted peer ids.)
(define ev/trusts? (fn (trust peer-id) (ev-fed-member? peer-id trust)))
;; The trusted subset of a peer list.
(define
ev/trusted-peers
(fn
(peers trust)
(filter (fn (p) (ev/trusts? trust (ev/peer-id p))) peers)))
;; Tag occurrences with provenance.
(define ev-tag-origin (fn (occs origin) (map (fn (o) {:id (get o :id) :start (get o :start) :end (get o :end) :origin origin}) occs)))
;; A peer's agenda over [ws, we), tagged with the peer's id as :origin.
(define
ev/peer-agenda
(fn
(peer ws we)
(ev-tag-origin (ev/agenda (ev/peer-store peer) ws we) (ev/peer-id peer))))
;; ---- merge (sorted by start, then origin for ties) ----
(define
ev-fed-before?
(fn
(a c)
(cond
((< (get a :start) (get c :start)) true)
((> (get a :start) (get c :start)) false)
(else (< (str (get a :origin)) (str (get c :origin)))))))
(define
ev-fed-insert
(fn
(x sorted)
(cond
((empty? sorted) (list x))
((ev-fed-before? x (first sorted)) (cons x sorted))
(else (cons (first sorted) (ev-fed-insert x (rest sorted)))))))
(define
ev-fed-sort
(fn (xs) (reduce (fn (acc x) (ev-fed-insert x acc)) (list) xs)))
;; Local agenda (origin :local) merged with every TRUSTED peer's agenda,
;; sorted by start. Untrusted peers contribute nothing.
(define
ev/federated-agenda
(fn
(local-store peers trust ws we)
(let
((acc (list)))
(begin
(for-each
(fn (o) (append! acc o))
(ev-tag-origin (ev/agenda local-store ws we) :local))
(for-each
(fn
(peer)
(when
(ev/trusts? trust (ev/peer-id peer))
(for-each
(fn (o) (append! acc o))
(ev/peer-agenda peer ws we))))
peers)
(ev-fed-sort acc)))))
;; Filter a federated agenda to occurrences from one origin.
(define
ev/from-origin
(fn
(agenda origin)
(filter (fn (o) (= (get o :origin) origin)) agenda)))
;; ---- federated free/busy ----
;; A peer's published busy intervals for `actor` ((start end) ...), or empty.
(define
ev/peer-busy
(fn
(peer actor)
(let
((row (ev-fed-assoc actor (ev/peer-busy-table peer))))
(if (nil? row) (list) (first (rest row))))))
(define
ev-fed-assoc
(fn
(k pairs)
(cond
((empty? pairs) nil)
((= (first (first pairs)) k) (first pairs))
(else (ev-fed-assoc k (rest pairs))))))
;; All busy intervals for `actor` across the LOCAL availability db plus every
;; TRUSTED peer's published free/busy, merged and sorted by start.
;; `local-db` is an availability db (see availability.sx ev-build-avail).
(define
ev/federated-busy
(fn
(local-db peers trust actor)
(let
((acc (list)))
(begin
(for-each (fn (iv) (append! acc iv)) (ev-busy local-db actor))
(for-each
(fn
(peer)
(when
(ev/trusts? trust (ev/peer-id peer))
(for-each
(fn (iv) (append! acc iv))
(ev/peer-busy peer actor))))
peers)
(ev-sort-lists acc)))))
;; Half-open overlap of interval (s e) with window [qs, qe).
(define
ev-fed-overlaps?
(fn (iv qs qe) (and (< (first iv) qe) (< qs (first (rest iv))))))
;; Is `actor` free across [qs, qe) considering local + trusted-peer busy?
(define
ev/federated-free?
(fn
(local-db peers trust actor qs qe)
(not
(some
(fn (iv) (ev-fed-overlaps? iv qs qe))
(ev/federated-busy local-db peers trust actor)))))
;; ---- injected transport (real fed-sx / signed fetch) ----
;; The in-process merge above expands a peer's local :store directly. In
;; production a peer's agenda arrives over a transport. `fetch` abstracts that:
;; (fetch peer-id ws we) -> {:status :ok :occurrences (...)} | {:status :error :reason ...}
;; The same merge works for any transport; an unreachable peer (:error) is
;; skipped (graceful degradation), never breaking the agenda.
(define
ev-find-peer
(fn
(peers pid)
(cond
((empty? peers) nil)
((= (ev/peer-id (first peers)) pid) (first peers))
(else (ev-find-peer (rest peers) pid)))))
;; In-process transport adapter: resolves a peer-id against a peer list and
;; expands its :store. Lets the in-process model run through the same `fetch`
;; interface a remote transport implements.
(define
ev/peer-fetch
(fn
(peers)
(fn
(pid ws we)
(let
((p (ev-find-peer peers pid)))
(if
(nil? p)
{:status :error :reason :unknown-peer}
{:status :ok :occurrences (ev/agenda (ev/peer-store p) ws we)})))))
;; Local agenda (:local) merged with each trusted peer's agenda fetched via the
;; injected `fetch` transport, sorted by start, tagged with :origin. Peers that
;; fail to fetch contribute nothing.
(define
ev/federated-agenda-via
(fn
(local-store trusted-ids ws we fetch)
(let
((acc (list)))
(begin
(for-each
(fn (o) (append! acc o))
(ev-tag-origin (ev/agenda local-store ws we) :local))
(for-each
(fn
(pid)
(let
((res (fetch pid ws we)))
(when
(= (get res :status) :ok)
(for-each
(fn (o) (append! acc o))
(ev-tag-origin (get res :occurrences) pid)))))
trusted-ids)
(ev-fed-sort acc)))))
;; Reachability report: ((peer-id :ok|:error) ...) for the trusted peers.
(define
ev/federation-status
(fn
(trusted-ids ws we fetch)
(map
(fn (pid) (list pid (get (fetch pid ws we) :status)))
trusted-ids)))

482
lib/events/ical.sx Normal file
View File

@@ -0,0 +1,482 @@
;; lib/events/ical.sx — iCalendar (RFC 5545) export.
;;
;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be
;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC
;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The
;; full RRULE / EXDATE / RDATE model maps directly to the standard properties.
;;
;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list
;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render`
;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx.
;; ---- formatting helpers ----
(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n))))
(define
ev-ical-pad4
(fn
(n)
(cond
((< n 10) (str "000" n))
((< n 100) (str "00" n))
((< n 1000) (str "0" n))
(else (str n)))))
(define
ev-ical-nth
(fn
(xs i)
(if
(= i 0)
(first xs)
(ev-ical-nth (rest xs) (- i 1)))))
(define
ev-ical-join
(fn
(parts sep)
(if
(empty? parts)
""
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
;; An epoch-minute as an iCal basic-format stamp (no zone suffix).
(define
ev-ical-dt-stamp
(fn
(t)
(let
((civ (ev-dt->civil t)) (tod (ev-dt-tod t)))
(str
(ev-ical-pad4 (ev-civ-y civ))
(ev-ical-pad2 (ev-civ-m civ))
(ev-ical-pad2 (ev-civ-d civ))
"T"
(ev-ical-pad2 (quotient tod 60))
(ev-ical-pad2 (modulo tod 60))
"00"))))
;; A UTC epoch-minute as a UTC stamp (trailing Z).
(define ev-ical-dt (fn (t) (str (ev-ical-dt-stamp t) "Z")))
;; A local epoch-minute as a floating/local stamp (no Z) — used with TZID.
(define ev-ical-dt-local ev-ical-dt-stamp)
;; A UTC offset in minutes as "+HHMM" / "-HHMM".
(define
ev-ical-offset
(fn
(mins)
(let
((a (abs mins)))
(str
(if (< mins 0) "-" "+")
(ev-ical-pad2 (quotient a 60))
(ev-ical-pad2 (modulo a 60))))))
;; A duration in minutes as an iCal DURATION value (PT#H#M).
(define
ev-ical-duration
(fn
(mins)
(let
((h (quotient mins 60)) (m (modulo mins 60)))
(cond
((and (> h 0) (> m 0)) (str "PT" h "H" m "M"))
((> h 0) (str "PT" h "H"))
(else (str "PT" m "M"))))))
(define
ev-ical-wd
(fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w)))
(define
ev-ical-freq
(fn
(f)
(cond
((= f :daily) "DAILY")
((= f :weekly) "WEEKLY")
((= f :monthly) "MONTHLY")
(else "DAILY"))))
;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday
;; {:ord :wd} -> "2TU" / "-1FR".
(define
ev-ical-byday-token
(fn
(e)
(if
(dict? e)
(str (get e :ord) (ev-ical-wd (get e :wd)))
(ev-ical-wd e))))
;; UNTIL converter: per RFC 5545, even a TZID DTSTART requires UNTIL in UTC, so
;; a tz event converts its (local) UNTIL to UTC; a non-tz event passes through.
(define
ev-ical-conv
(fn
(event)
(let
((tz (get event :tz)))
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
;; ---- VTIMEZONE ----
;; A tz event exports DTSTART;TZID=<name>:<local time> and the VCALENDAR carries
;; a VTIMEZONE block defining the zone's DST rules, so a client recurs at a
;; fixed WALL-CLOCK time (DST-correct) rather than fixed UTC.
;; A DST transition rule -> "FREQ=YEARLY;BYMONTH=<m>;BYDAY=<ord><WD>".
(define
ev-ical-vtz-rrule
(fn
(rule)
(str
"FREQ=YEARLY;BYMONTH="
(get rule :month)
";BYDAY="
(get rule :ord)
(ev-ical-wd (get rule :wd)))))
;; The transition's DTSTART (local time of the FROM offset) in a reference year.
(define
ev-ical-vtz-dtstart
(fn
(rule from-offset)
(let
((day (ev-resolve-nth-weekday 1970 (get rule :month) (get rule :ord) (get rule :wd))))
(ev-ical-dt-local
(+ (* (ev-days-from-civil 1970 (get rule :month) day) 1440)
(get rule :time)
from-offset)))))
;; The VTIMEZONE content lines for a zone (DAYLIGHT + STANDARD for :dst; a
;; single STANDARD for :fixed).
(define
ev-ical-vtimezone
(fn
(tz)
(if
(= (get tz :kind) :dst)
(let
((std (get tz :std-offset))
(dst (get tz :dst-offset))
(sr (get tz :dst-start))
(er (get tz :dst-end)))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:DAYLIGHT"
(str "DTSTART:" (ev-ical-vtz-dtstart sr std))
(str "TZOFFSETFROM:" (ev-ical-offset std))
(str "TZOFFSETTO:" (ev-ical-offset dst))
(str "RRULE:" (ev-ical-vtz-rrule sr))
"END:DAYLIGHT"
"BEGIN:STANDARD"
(str "DTSTART:" (ev-ical-vtz-dtstart er dst))
(str "TZOFFSETFROM:" (ev-ical-offset dst))
(str "TZOFFSETTO:" (ev-ical-offset std))
(str "RRULE:" (ev-ical-vtz-rrule er))
"END:STANDARD"
"END:VTIMEZONE"))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:STANDARD"
"DTSTART:19700101T000000"
(str "TZOFFSETFROM:" (ev-ical-offset (get tz :offset)))
(str "TZOFFSETTO:" (ev-ical-offset (get tz :offset)))
"END:STANDARD"
"END:VTIMEZONE"))))
;; ---- RRULE ----
(define
ev-ical-rrule
(fn
(rrule conv)
(let
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
(begin
(when
(and
(not (nil? (get rrule :interval)))
(> (get rrule :interval) 1))
(append! parts (str "INTERVAL=" (get rrule :interval))))
(when
(not (nil? (get rrule :count)))
(append! parts (str "COUNT=" (get rrule :count))))
(when
(not (nil? (get rrule :until)))
(append! parts (str "UNTIL=" (ev-ical-dt (conv (get rrule :until))))))
(when
(not (nil? (get rrule :byday)))
(append!
parts
(str
"BYDAY="
(ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ","))))
(when
(not (nil? (get rrule :bymonthday)))
(append!
parts
(str
"BYMONTHDAY="
(ev-ical-join
(map (fn (d) (str d)) (get rrule :bymonthday))
","))))
(str "RRULE:" (ev-ical-join parts ";"))))))
;; ---- VEVENT / VCALENDAR ----
;; The VEVENT content lines for an event (list of strings). A tz event uses
;; DTSTART;TZID=<name>:<local> (matched by a VTIMEZONE at the VCALENDAR level)
;; with EXDATE/RDATE in the same TZID-local form; UNTIL is always UTC. A non-tz
;; event uses UTC `Z` stamps throughout.
(define
ev/event->ical-lines
(fn
(event)
(let
((lines (list "BEGIN:VEVENT"))
(conv (ev-ical-conv event))
(tz (get event :tz)))
(let
((dtparam (if (nil? tz) "" (str ";TZID=" (get tz :name))))
(fmt (if (nil? tz) ev-ical-dt ev-ical-dt-local)))
(begin
(append! lines (str "UID:" (get event :id)))
(append! lines (str "SUMMARY:" (get event :id)))
(append! lines (str "DTSTART" dtparam ":" (fmt (get event :dtstart))))
(append!
lines
(str "DURATION:" (ev-ical-duration (get event :duration))))
(when
(not (nil? (get event :rrule)))
(append! lines (ev-ical-rrule (get event :rrule) conv)))
(when
(and
(not (nil? (get event :exdate)))
(> (len (get event :exdate)) 0))
(append!
lines
(str
"EXDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :exdate)) ","))))
(when
(and
(not (nil? (get event :rdate)))
(> (len (get event :rdate)) 0))
(append!
lines
(str
"RDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :rdate)) ","))))
(append! lines "END:VEVENT")
lines)))))
;; Collect the distinct timezones used by a list of events (by :name).
(define
ev-ical-distinct-tzs
(fn
(events)
(reduce
(fn
(acc ev)
(let
((tz (get ev :tz)))
(if
(or (nil? tz) (ev-ical-tz-seen? acc (get tz :name)))
acc
(append acc (list tz)))))
(list)
events)))
(define
ev-ical-tz-seen?
(fn
(tzs name)
(cond
((empty? tzs) false)
((= (get (first tzs) :name) name) true)
(else (ev-ical-tz-seen? (rest tzs) name)))))
;; A full VCALENDAR (list of content lines): a VTIMEZONE block for each distinct
;; zone the events reference, then every VEVENT.
(define
ev/events->ical-lines
(fn
(events)
(let
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
(begin
(for-each
(fn
(tz)
(for-each (fn (l) (append! lines l)) (ev-ical-vtimezone tz)))
(ev-ical-distinct-tzs events))
(for-each
(fn
(ev)
(for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev)))
events)
(append! lines "END:VCALENDAR")
lines))))
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
;; ---- import (parse VEVENT/VCALENDAR back into events) ----
;; Inverse of the export above: parse iCalendar content lines into event dicts
;; (ev-event-full shape). Capacity is not an iCal property, so imported events
;; default to capacity 0 — set it after import if needed.
;; "20260601T180000Z" -> UTC epoch-minutes.
(define
ev-ical-parse-dt
(fn
(s)
(ev-dt
(string->number (substring s 0 4))
(string->number (substring s 4 6))
(string->number (substring s 6 8))
(string->number (substring s 9 11))
(string->number (substring s 11 13)))))
;; "30M" / "" -> minutes.
(define
ev-ical-parse-min
(fn
(s)
(if (= (string-length s) 0) 0 (string->number (first (split s "M"))))))
;; "PT1H30M" / "PT1H" / "PT30M" -> minutes.
(define
ev-ical-parse-duration
(fn
(s)
(let
((body (substring s 2 (string-length s))))
(let
((hparts (split body "H")))
(if
(> (len hparts) 1)
(+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts))))
(ev-ical-parse-min body))))))
(define
ev-ical-wd->num
(fn
(tok)
(cond
((= tok "MO") 0)
((= tok "TU") 1)
((= tok "WE") 2)
((= tok "TH") 3)
((= tok "FR") 4)
((= tok "SA") 5)
((= tok "SU") 6)
(else 0))))
;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4}
(define
ev-ical-parse-byday-token
(fn
(tok)
(let
((n (string-length tok)))
(if
(= n 2)
(ev-ical-wd->num tok)
{:ord (string->number (substring tok 0 (- n 2)))
:wd (ev-ical-wd->num (substring tok (- n 2) n))}))))
(define
ev-ical-parse-freq
(fn
(v)
(cond
((= v "DAILY") :daily)
((= v "WEEKLY") :weekly)
((= v "MONTHLY") :monthly)
(else :daily))))
;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict.
(define
ev-ical-parse-rrule
(fn
(val)
(let
((rr {}))
(begin
(for-each
(fn
(p)
(let
((kv (split p "=")))
(let
((k (first kv)) (v (first (rest kv))))
(cond
((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v)))
((= k "INTERVAL") (dict-set! rr :interval (string->number v)))
((= k "COUNT") (dict-set! rr :count (string->number v)))
((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v)))
((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ","))))
((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ","))))
(else nil)))))
(split val ";"))
rr))))
;; Parse a VEVENT's content lines into an event dict.
(define
ev/ical-lines->event
(fn
(lines)
(let
((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list)))
(begin
(for-each
(fn
(line)
(let
((kv (split line ":")))
(when
(> (len kv) 1)
(let
;; strip any property parameters (e.g. ";TZID=...") from the key
((k (first (split (first kv) ";"))) (v (first (rest kv))))
(cond
((= k "UID") (dict-set! ev :id (string->symbol v)))
((= k "DTSTART") (dict-set! ev :dtstart (ev-ical-parse-dt v)))
((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v)))
((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v)))
((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ","))))
((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ","))))
(else nil))))))
lines)
(dict-set! ev :exdate exd)
(dict-set! ev :rdate rd)
ev))))
;; Split a VCALENDAR line list into per-VEVENT line groups.
(define
ev-ical-group-vevents
(fn
(lines cur in acc)
(cond
((empty? lines) acc)
((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc))
((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur))))
(in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc))
(else (ev-ical-group-vevents (rest lines) cur false acc)))))
;; Parse a VCALENDAR line list into a list of events.
(define
ev/parse-vcalendar
(fn
(lines)
(map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list)))))

97
lib/events/notify.sx Normal file
View File

@@ -0,0 +1,97 @@
;; lib/events/notify.sx — durable notification delivery flows over an injected
;; transport (lib/flow).
;;
;; Reminders and digests are durable `flow`s: a flow `request`s delivery (a
;; suspend point), the HOST performs the actual send via an injected `dispatch`
;; (the transport — email/push/etc.), and resumes the flow with the outcome.
;; Because flow uses deterministic replay, a completed delivery is never re-run
;; on recovery; the host owns IO and persistence.
;;
;; Delivery is AT-LEAST-ONCE with idempotency. Each message carries an id (the
;; idempotency key). Two protections stop double-delivery:
;; 1. The transport dedups by id — a re-send of a delivered id is a no-op
;; that still reports ok, so a retry never produces two pings.
;; 2. flow's replay log records each resolved request, so recovery replays the
;; logged outcome instead of re-issuing the send.
;;
;; Retry/backoff rides flow suspend/resume: each attempt issues a request with a
;; DISTINCT tag `(deliver <id> <n>)` — distinct tags keep deterministic replay
;; correct across retries. The dispatch returns (ok info) to finish or
;; (retry reason) to try again, bounded by `maxn` (then (failed id reason)).
;;
;; A message is a 3-element list (id recipient body). The transport is generic
;; and injected — when feed/notify lands, both consumers share one transport,
;; so this delivery core is a candidate for extraction to `delivery-on-sx`.
;;
;; The Scheme flow source below loads into a flow env (see lib/flow/api.sx).
;; `ev/notify-run` prepends it to a caller program and evaluates in the shared
;; flow env.
(define
ev-notify-flows-src
"(define (ev-msg-id m) (car m))\n (define (ev-msg-recipient m) (car (cdr m)))\n (define (ev-msg-body m) (car (cdr (cdr m))))\n (define (ev-mem x xs)\n (if (null? xs) #f (if (equal? x (car xs)) #t (ev-mem x (cdr xs)))))\n (define (ev-notify-attempt m n maxn)\n (let ((r (request (list (quote deliver) (ev-msg-id m) n) m)))\n (if (eq? (car r) (quote ok))\n (list (quote delivered) (ev-msg-id m) n)\n (if (>= n maxn)\n (list (quote failed) (ev-msg-id m) (car (cdr r)))\n (ev-notify-attempt m (+ n 1) maxn)))))\n (define (ev-deliver-reminder maxn)\n (flow-node (lambda (m) (ev-notify-attempt m 1 maxn))))\n (define (ev-digest-step ms maxn)\n (if (null? ms)\n (list)\n (cons (ev-notify-attempt (car ms) 1 maxn)\n (ev-digest-step (cdr ms) maxn))))\n (define (ev-deliver-digest maxn)\n (flow-node (lambda (ms) (ev-digest-step ms maxn))))")
;; Run a Scheme flow program with the notify flows preloaded, in the shared
;; flow env. Returns the program's value (SX-native).
(define
ev/notify-run
(fn (prog) (flow-run (str ev-notify-flows-src "\n" prog))))
;; ---- end-to-end delivery: SX messages -> the notify flow ----
;; Bridges the SX notification-derivation modules (reminders / booking-notify /
;; reschedule) to the durable delivery flow. An SX message (id recipient body)
;; is serialized to s-expression text and spliced into the Scheme program as
;; quoted data, then the digest flow delivers the batch over an injected
;; transport. Strings round-trip through the guest Scheme as {:scm-string ...}
;; boxes; results are unboxed back to plain SX.
;; A default transport (Scheme source): always reports delivered.
(define ev-notify-ok-transport "(lambda (k p) (list (quote ok) (quote sent)))")
(define
ev-notify-join
(fn
(parts sep)
(if
(empty? parts)
""
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
(define ev-msg->quoted (fn (m) (str "(quote " (serialize m) ")")))
(define
ev-msgs->scheme
(fn
(msgs)
(str "(list " (ev-notify-join (map ev-msg->quoted msgs) " ") ")")))
(define
ev-unbox-str
(fn
(x)
(if (and (dict? x) (has-key? x :scm-string)) (get x :scm-string) x)))
(define
ev-unbox-result
(fn (r) (map (fn (item) (map ev-unbox-str item)) r)))
;; Deliver a list of SX messages through the digest flow over `transport-src`
;; (a Scheme (kind payload) -> (ok ..)|(retry reason) lambda source). `maxn`
;; bounds retries per message, `maxticks` bounds host service ticks. Returns the
;; per-message outcomes unboxed: (("delivered"|"failed" <id> <n-or-reason>) ...)
(define
ev/deliver-messages
(fn
(msgs transport-src maxn maxticks)
(ev-unbox-result
(ev/notify-run
(str
"(define msgs "
(ev-msgs->scheme msgs)
") (if (null? msgs) (list) (let ((s (flow/start (ev-deliver-digest "
maxn
") msgs))) (begin (flow-run-host "
transport-src
" "
maxticks
") (flow/result (car (cdr s))))))")))))

147
lib/events/reminders.sx Normal file
View File

@@ -0,0 +1,147 @@
;; lib/events/reminders.sx — derive reminder + digest messages from the agenda.
;;
;; Bridges the schedule (calendar) and the durable roster (booking on persist)
;; to the notification layer (notify.sx). For each booked attendee of each
;; upcoming occurrence we derive a reminder message that fires `lead` minutes
;; before the occurrence starts. Each message has a deterministic idempotency
;; key — occ-key / recipient / lead — so re-deriving over an overlapping window
;; never produces a duplicate ping (the notify transport dedups on this id).
;;
;; A reminder is a dict:
;; {:id :recipient :event :start :fire-at}
;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape.
;; Reminders for one occurrence: one per booked attendee (durable roster).
(define
ev/occurrence-reminders
(fn
(b occ lead)
(let
((occ-key (ev-occ-key occ))
(start (get occ :start))
(evid (get occ :id)))
(map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ)))))
;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties).
(define
ev-rem-before?
(fn
(a c)
(cond
((< (get a :fire-at) (get c :fire-at)) true)
((> (get a :fire-at) (get c :fire-at)) false)
(else (< (get a :id) (get c :id))))))
(define
ev-rem-insert
(fn
(r sorted)
(cond
((empty? sorted) (list r))
((ev-rem-before? r (first sorted)) (cons r sorted))
(else (cons (first sorted) (ev-rem-insert r (rest sorted)))))))
(define
ev-rem-sort
(fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs)))
;; All reminders across the agenda in [ws, we), ascending by fire-at.
(define
ev/agenda-reminders
(fn
(b store ws we lead)
(let
((acc (list)))
(begin
(for-each
(fn
(occ)
(for-each
(fn (r) (append! acc r))
(ev/occurrence-reminders b occ lead)))
(ev/agenda store ws we))
(ev-rem-sort acc)))))
;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler
;; should hand to the notify transport at time `now`.
(define
ev/due-reminders
(fn
(reminders now)
(filter (fn (r) (<= (get r :fire-at) now)) reminders)))
;; Project a reminder to notify's (id recipient body) wire shape.
(define
ev/reminder->msg
(fn
(r)
(list
(get r :id)
(get r :recipient)
(list :reminder (get r :event) (get r :start)))))
;; ---- digests ----
;; The occurrences `actor` is booked into (durable roster), within window.
(define
ev/agenda-for-p
(fn
(b store actor ws we)
(filter
(fn (occ) (ev-bk-member? actor (ev/roster-occ b occ)))
(ev/agenda store ws we))))
;; A single digest message summarising an actor's upcoming booked occurrences.
;; :items is ({:event :start} ...); empty when the actor has nothing booked.
(define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor}))
;; ---- reschedule notifications ----
;; When an event carries per-occurrence overrides (ev-with-override), every
;; attendee booked at the ORIGINAL start should be told the new time. Bookings
;; were made against the original occ-key (id@orig-start), so we read that
;; roster. Idempotency key encodes the original key and the new start, so
;; re-deriving the same reschedule never double-notifies.
(define
ev/reschedule-notifications
(fn
(b event)
(let
((overrides (ev-or (get event :overrides) (list)))
(evid (get event :id))
(dur (get event :duration)))
(reduce
(fn
(acc entry)
(let
((orig-start (first entry))
(ov (first (rest entry))))
(let
((occ (ev-occ evid orig-start dur))
(new-start (get ov :start))
(new-duration (get ov :duration)))
(let
((key (ev-occ-key occ)))
(append
acc
(map
(fn
(actor)
{:id (str key "/reschedule/" new-start)
:recipient actor
:event evid
:old-start orig-start
:new-start new-start
:new-duration new-duration})
(ev/roster-occ b occ)))))))
(list)
overrides))))
;; Project a reschedule notification to notify's (id recipient body) shape.
(define
ev/reschedule-notify->msg
(fn
(r)
(list
(get r :id)
(get r :recipient)
(list :rescheduled (get r :event) (get r :old-start) (get r :new-start)))))

View File

@@ -0,0 +1,21 @@
{
"lang": "events",
"total_passed": 391,
"total_failed": 0,
"total": 391,
"suites": [
{"name":"calendar","passed":51,"failed":0,"total":51},
{"name":"timezone","passed":25,"failed":0,"total":25},
{"name":"ical","passed":63,"failed":0,"total":63},
{"name":"availability","passed":22,"failed":0,"total":22},
{"name":"api","passed":41,"failed":0,"total":41},
{"name":"booking","passed":82,"failed":0,"total":82},
{"name":"booking-notify","passed":11,"failed":0,"total":11},
{"name":"ticket","passed":31,"failed":0,"total":31},
{"name":"notify","passed":7,"failed":0,"total":7},
{"name":"reminders","passed":21,"failed":0,"total":21},
{"name":"federation","passed":29,"failed":0,"total":29},
{"name":"integration","passed":8,"failed":0,"total":8}
],
"generated": "2026-06-10T22:03:34+00:00"
}

18
lib/events/scoreboard.md Normal file
View File

@@ -0,0 +1,18 @@
# events scoreboard
**391 / 391 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
| calendar | 51 | 51 | ok |
| timezone | 25 | 25 | ok |
| ical | 63 | 63 | ok |
| availability | 22 | 22 | ok |
| api | 41 | 41 | ok |
| booking | 82 | 82 | ok |
| booking-notify | 11 | 11 | ok |
| ticket | 31 | 31 | ok |
| notify | 7 | 7 | ok |
| reminders | 21 | 21 | ok |
| federation | 29 | 29 | ok |
| integration | 8 | 8 | ok |

392
lib/events/tests/api.sx Normal file
View File

@@ -0,0 +1,392 @@
;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book).
(define ev-api-pass 0)
(define ev-api-fail 0)
(define ev-api-failures (list))
(define
ev-api-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-api-pass (+ ev-api-pass 1))
(do
(set! ev-api-fail (+ ev-api-fail 1))
(append!
ev-api-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences).
(define
ev-api-store
(fn
()
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 18 0)
60
{:freq :weekly :count 4 :byday (list 0 2)}
20)))
(define
ev-api-run-all!
(fn
()
(let
((s0 (ev-api-store)))
(let
((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1))))
(let
((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs))))))
(do
(ev-api-check!
"agenda expands weekly class to four occurrences"
(map (fn (o) (ev-dt->civil (get o :start))) occs)
(list
(list 2026 6 1)
(list 2026 6 3)
(list 2026 6 8)
(list 2026 6 10)))
(ev-api-check!
"empty store has empty agenda"
(ev/agenda
(ev/empty)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
(list))
(ev-api-check!
"max duration reflects scheduled events"
(ev/store-max-duration s0)
60)
(ev-api-check!
"max duration of empty store is zero"
(ev/store-max-duration (ev/empty))
0)
(ev-api-check!
"event-by-id finds the scheduled event"
(get (ev/event-by-id s0 (quote yoga)) :capacity)
20)
(ev-api-check!
"event-by-id is nil for unknown id"
(ev/event-by-id s0 (quote nope))
nil)
(ev-api-check!
"agenda-for lists only booked occurrences"
(map
(fn (o) (ev-dt->civil (get o :start)))
(ev/agenda-for
s1
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 3)))
(ev-api-check!
"agenda-for empty for unbooked actor"
(ev/agenda-for
s1
(quote zed)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
(list))
(ev-api-check!
"free? false during a booked occurrence"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 18 30)
(ev-dt 2026 6 1 19 0))
false)
(ev-api-check!
"free? true in an open window"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 10 0))
true)
(ev-api-check!
"free? half-open at occurrence end"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 19 0)
(ev-dt 2026 6 1 20 0))
true)
(ev-api-check!
"free? true for an actor who booked nothing"
(ev/free?
s1
(quote zed)
(ev-dt 2026 6 1 18 0)
(ev-dt 2026 6 1 19 0))
true)
(ev-api-check!
"next-free skips the booked slot to the hour after"
(ev-dt-tod
(ev/next-free
s1
(quote nia)
(ev-dt
2026
6
1
18
0)
60
(ev-dt
2026
6
1
23
0)))
(* 19 60))
(ev-api-check!
"next-free returns `after` when already open"
(ev/next-free
s1
(quote nia)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 9 0))
(ev-api-check!
"no conflict among disjoint bookings"
(ev/has-conflict?
s1
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
false)
(let
((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60)))))
(ev-api-check!
"overlapping second booking creates a conflict"
(ev/has-conflict?
sc
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
true))
(let
((b (persist/open)) (occ1 (first occs)))
(do
(let
((sp (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 5 9 0) 30 nil 2)))
(let
((occ (ev-occ (quote clinic) (ev-dt 2026 6 5 9 0) 30)))
(do
(ev-api-check!
"durable book returns booked"
(get (ev/book-occ! b sp (quote a) occ) :status)
:booked)
(ev/book-occ! b sp (quote c) occ)
(ev-api-check!
"durable book past capacity is full"
(get (ev/book-occ! b sp (quote d) occ) :status)
:full)
(ev-api-check!
"durable roster reflects persisted bookings"
(ev/roster-occ b occ)
(list (quote a) (quote c)))
(ev-api-check!
"durable seats-left honours capacity"
(ev/seats-left-occ b sp occ)
0)
(ev-api-check!
"persist free? false during a durable booking"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
9
10)
(ev-dt
2026
6
5
9
20))
false)
(ev-api-check!
"persist free? true in an open window"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
10
0)
(ev-dt
2026
6
5
10
30))
true)
(ev/cancel-occ! b sp (quote a) occ)
(ev-api-check!
"durable cancel frees a seat"
(ev/seats-left-occ b sp occ)
1)
(ev-api-check!
"persist free? true after cancellation"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
9
10)
(ev-dt
2026
6
5
9
20))
true))))))))))))
;; ---- conflict-checked booking ----
(define
ev-api-cf-run-all!
(fn
()
(let
((b (persist/open))
(store
(ev/schedule
(ev/schedule
(ev/schedule (ev/empty) (quote a) (ev-dt 2026 6 1 9 0) 60 nil 10)
(quote bb)
(ev-dt 2026 6 1 9 30)
60
nil
10)
(quote c)
(ev-dt 2026 6 1 11 0)
60
nil
10)))
(let
((oa (ev-occ (quote a) (ev-dt 2026 6 1 9 0) 60))
(ob (ev-occ (quote bb) (ev-dt 2026 6 1 9 30) 60))
(oc (ev-occ (quote c) (ev-dt 2026 6 1 11 0) 60)))
(do
(ev-api-check!
"first checked booking succeeds"
(get (ev/book-checked! b store (quote nia) oa) :status)
:booked)
(ev-api-check!
"overlapping different-event booking is a time conflict"
(get (ev/book-checked! b store (quote nia) ob) :status)
:time-conflict)
(ev-api-check!
"the clashing booking did not land on the roster"
(ev/roster-occ b ob)
(list))
(ev-api-check!
"a non-overlapping booking is allowed"
(get (ev/book-checked! b store (quote nia) oc) :status)
:booked)
(ev-api-check!
"re-booking the same occurrence is idempotent, not a conflict"
(get (ev/book-checked! b store (quote nia) oa) :status)
:already)
;; a different actor is unaffected by nia's bookings
(ev-api-check!
"another actor may take the overlapping slot"
(get (ev/book-checked! b store (quote ola) ob) :status)
:booked)
(ev-api-check!
"would-time-conflict? predicate agrees"
(ev/would-time-conflict? b store (quote nia) ob)
true)
(ev-api-check!
"would-time-conflict? false for a free slot"
(ev/would-time-conflict? b store (quote zed) ob)
false))))))
;; ---- whole-series booking ----
(define
ev-api-sr-run-all!
(fn
()
(let
((b (persist/open))
(store
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 18 0)
60
{:freq :weekly :byday (list 0 2) :count 4}
20))
(ws (ev-date 2026 6 1))
(we (ev-date 2026 7 1)))
(do
(let
((res (ev/book-series! b store (quote nia) (quote yoga) ws we)))
(do
(ev-api-check! "series booking covers all four occurrences" (len res) 4)
(ev-api-check! "all occurrences booked" (ev/series-count res :booked) 4)
(ev-api-check!
"actor is now booked into the whole series"
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
4)))
;; re-booking the series is idempotent
(ev-api-check!
"re-booking the series is idempotent"
(ev/series-count (ev/book-series! b store (quote nia) (quote yoga) ws we) :already)
4)
;; cancel the whole series
(let
((res (ev/cancel-series! b store (quote nia) (quote yoga) ws we)))
(do
(ev-api-check! "series cancel reports four cancellations" (ev/series-count res :cancelled) 4)
(ev-api-check!
"actor booked into nothing after series cancel"
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
0)))
;; capacity interacts per-occurrence: fill one occurrence first
(let
((b2 (persist/open))
(s2
(ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
(do
(ev/book-occ! b2 s2 (quote x) (ev-occ (quote clinic) (ev-dt 2026 6 2 9 0) 30))
(let
((res (ev/book-series! b2 s2 (quote nia) (quote clinic) (ev-date 2026 6 1) (ev-date 2026 6 10))))
(do
(ev-api-check! "series booking succeeds on free occurrences" (ev/series-count res :booked) 2)
(ev-api-check! "series booking hits :full where capacity is taken" (ev/series-count res :full) 1)))))
;; unknown event id
(ev-api-check!
"series booking an unknown event yields no results"
(ev/book-series! b store (quote nia) (quote nope) ws we)
(list))))))
(define
ev-api-tests-run!
(fn
()
(do
(set! ev-api-pass 0)
(set! ev-api-fail 0)
(set! ev-api-failures (list))
(ev-api-run-all!)
(ev-api-cf-run-all!)
(ev-api-sr-run-all!)
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))

View File

@@ -0,0 +1,331 @@
;; lib/events/tests/availability.sx — free/busy + conflict rules on Datalog.
(define ev-av-pass 0)
(define ev-av-fail 0)
(define ev-av-failures (list))
(define
ev-av-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-av-pass (+ ev-av-pass 1))
(do
(set! ev-av-fail (+ ev-av-fail 1))
(append!
ev-av-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Fixture: three occurrences on 2026-06-01.
;; standup 09:0009:30 review 09:1510:15 (overlaps standup)
;; lunch 12:0013:00
(define
ev-av-occs
(fn
()
(list
(ev-occ
(quote standup)
(ev-dt 2026 6 1 9 0)
30)
(ev-occ
(quote review)
(ev-dt 2026 6 1 9 15)
60)
(ev-occ
(quote lunch)
(ev-dt 2026 6 1 12 0)
60))))
(define ev-av-key (fn (id start) (str id "@" start)))
;; alice: standup + review (overlap → conflict). bob: lunch only.
(define
ev-av-db
(fn
()
(ev-avail-db
(ev-av-occs)
(list
(list
(quote alice)
(ev-av-key
(quote standup)
(ev-dt 2026 6 1 9 0)))
(list
(quote alice)
(ev-av-key
(quote review)
(ev-dt 2026 6 1 9 15)))
(list
(quote bob)
(ev-av-key
(quote lunch)
(ev-dt 2026 6 1 12 0)))))))
;; Disjoint fixture for slot search: 09:0010:00 then 10:3011:30 (a 30m gap).
(define
ev-av-gap-db
(fn
()
(ev-avail-db
(list
(ev-occ
(quote a)
(ev-dt 2026 6 1 9 0)
60)
(ev-occ
(quote b)
(ev-dt 2026 6 1 10 30)
60))
(list
(list
(quote sam)
(ev-av-key
(quote a)
(ev-dt 2026 6 1 9 0)))
(list
(quote sam)
(ev-av-key
(quote b)
(ev-dt 2026 6 1 10 30)))))))
(define
ev-av-run-all!
(fn
()
(let
((db (ev-av-db)))
(do
(ev-av-check!
"busy lists alice committed intervals ascending"
(ev-busy db (quote alice))
(list
(list
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 9 30))
(list
(ev-dt 2026 6 1 9 15)
(ev-dt 2026 6 1 10 15))))
(ev-av-check!
"busy lists bob single interval"
(ev-busy db (quote bob))
(list
(list
(ev-dt 2026 6 1 12 0)
(ev-dt 2026 6 1 13 0))))
(ev-av-check!
"busy empty for unknown actor"
(ev-busy db (quote carol))
(list))
(ev-av-check!
"alice has an overlap conflict"
(ev-has-conflict? db (quote alice))
true)
(ev-av-check!
"alice conflict reported once (canonical pair)"
(len (ev-conflicts db (quote alice)))
1)
(ev-av-check!
"bob has no conflict"
(ev-has-conflict? db (quote bob))
false)
(ev-av-check!
"non-overlapping bookings do not conflict"
(ev-has-conflict?
(ev-avail-db
(list
(ev-occ
(quote a)
(ev-dt
2026
6
1
9
0)
30)
(ev-occ
(quote b)
(ev-dt
2026
6
1
9
30)
30))
(list
(list
(quote dave)
(ev-av-key
(quote a)
(ev-dt
2026
6
1
9
0)))
(list
(quote dave)
(ev-av-key
(quote b)
(ev-dt
2026
6
1
9
30)))))
(quote dave))
false)
(ev-av-check!
"alice free in an empty window"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 13 0)
(ev-dt 2026 6 1 14 0))
true)
(ev-av-check!
"alice not free overlapping a booking"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 9 20)
(ev-dt 2026 6 1 9 40))
false)
(ev-av-check!
"free? is half-open at the trailing edge"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 10 15)
(ev-dt 2026 6 1 11 0))
true)
(ev-av-check!
"free? is half-open at the leading edge"
(ev-free?
db
(quote bob)
(ev-dt 2026 6 1 11 0)
(ev-dt 2026 6 1 12 0))
true)
(ev-av-check!
"free? false when window straddles a booking edge"
(ev-free?
db
(quote bob)
(ev-dt 2026 6 1 11 0)
(ev-dt 2026 6 1 12 1))
false)
(ev-av-check!
"free? query leaves db reusable (no leaked qwindow)"
(do
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 9 30))
(ev-busy db (quote bob)))
(list
(list
(ev-dt 2026 6 1 12 0)
(ev-dt 2026 6 1 13 0))))
(let
((gdb (ev-av-gap-db)))
(do
(ev-av-check!
"next-free finds the gap between bookings"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
30
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 10 0))
(ev-av-check!
"next-free skips a gap too short for the duration"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 11 30))
(ev-av-check!
"next-free returns `after` when already free"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 14 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 14 0))
(ev-av-check!
"next-free returns nil when nothing fits before horizon"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
120
(ev-dt 2026 6 1 11 0))
nil)
(ev-av-check!
"next-free for actor with no bookings is `after`"
(ev-next-free
gdb
(quote nobody)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 9 0))
(ev-av-check!
"next-free at exact edge of a booking (half-open)"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 10 0)
30
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 10 0))))
(let
((daily (ev-expand (ev-event (quote class) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 1) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(let
((db2 (ev-avail-db daily (map (fn (o) (list (quote sam) (ev-occ-key o))) daily))))
(do
(ev-av-check!
"expanded daily occurrences become busy intervals"
(len (ev-busy db2 (quote sam)))
3)
(ev-av-check!
"no conflicts among disjoint daily occurrences"
(ev-has-conflict? db2 (quote sam))
false)
(ev-av-check!
"busy on day two of the series"
(ev-free?
db2
(quote sam)
(ev-dt
2026
6
2
9
30)
(ev-dt
2026
6
2
9
45))
false))))))))
(define
ev-availability-tests-run!
(fn
()
(do
(set! ev-av-pass 0)
(set! ev-av-fail 0)
(set! ev-av-failures (list))
(ev-av-run-all!)
{:failures ev-av-failures :total (+ ev-av-pass ev-av-fail) :passed ev-av-pass :failed ev-av-fail})))

View File

@@ -0,0 +1,137 @@
;; lib/events/tests/booking-notify.sx — lifecycle notifications from the stream.
(define ev-bn-pass 0)
(define ev-bn-fail 0)
(define ev-bn-failures (list))
(define
ev-bn-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-bn-pass (+ ev-bn-pass 1))
(do
(set! ev-bn-fail (+ ev-bn-fail 1))
(append!
ev-bn-failures
(str name "\n expected: " expected "\n got: " got))))))
(define
ev-bn-kinds
(fn
(notifs)
(map (fn (n) (list (get n :recipient) (get n :kind))) notifs)))
(define
ev-bn-run-all!
(fn
()
(do
(let
((b (persist/open)))
(do
(ev/book! b "o" 1 (quote a))
(ev/waitlist! b "o" 1 (quote x))
(ev/cancel-promote! b "o" 1 (quote a))
(let
((ns (ev/booking-notifications b "o" (quote yoga))))
(do
(ev-bn-check!
"lifecycle notifications in order"
(ev-bn-kinds ns)
(list
(list (quote a) :booked)
(list (quote x) :waitlisted)
(list (quote a) :cancelled)
(list (quote x) :promoted)))
(ev-bn-check!
"promotion targets the waitlisted actor"
(map
(fn (n) (get n :recipient))
(ev/notify-of-kind ns :promoted))
(list (quote x)))
(ev-bn-check!
"a fresh booking is not flagged as a promotion"
(len (ev/notify-of-kind ns :booked))
1)
(ev-bn-check!
"every notification carries the event label"
(get (first ns) :event)
(quote yoga))))))
(let
((b (persist/open)))
(do
(ev/hold! b "p" 3 (quote q))
(ev/confirm! b "p" (quote q))
(ev-bn-check!
"hold then confirm notifications"
(ev-bn-kinds (ev/booking-notifications b "p" (quote gig)))
(list (list (quote q) :held) (list (quote q) :confirmed)))))
(let
((b (persist/open)))
(do
(ev/hold! b "r" 1 (quote q))
(ev/release! b "r" (quote q))
(ev-bn-check!
"hold then release notifications"
(ev-bn-kinds (ev/booking-notifications b "r" (quote gig)))
(list (list (quote q) :held) (list (quote q) :released)))))
(let
((b (persist/open)))
(do
(ev/book! b "k" 5 (quote a))
(ev/book! b "k" 5 (quote c))
(let
((ns (ev/booking-notifications b "k" (quote talk))))
(do
(ev-bn-check!
"notification ids are occ-key/seq"
(map (fn (n) (get n :id)) ns)
(list "k/1" "k/2"))
(ev-bn-check!
"re-deriving yields identical ids (idempotent)"
(map
(fn (n) (get n :id))
(ev/booking-notifications b "k" (quote talk)))
(list "k/1" "k/2"))))))
(let
((b (persist/open)))
(do
(ev/book! b "w" 5 (quote a))
(ev-bn-check!
"notification projects to (id recipient body)"
(ev/booking-notify->msg
(first (ev/booking-notifications b "w" (quote talk))))
(list
"w/1"
(quote a)
(list :booking-event :booked (quote talk))))))
(let
((b (persist/open)))
(do
(ev/book! b "u" 1 (quote a))
(ev/waitlist! b "u" 1 (quote x))
(ev/leave-waitlist! b "u" (quote x))
(ev-bn-check!
"leaving the waitlist emits no notification"
(len
(ev/notify-of-kind
(ev/booking-notifications b "u" (quote e))
:left-waitlist))
0)
(ev-bn-check!
"unbooked occurrence has no notifications"
(ev/booking-notifications b "empty" (quote e))
(list)))))))
(define
ev-booking-notify-tests-run!
(fn
()
(do
(set! ev-bn-pass 0)
(set! ev-bn-fail 0)
(set! ev-bn-failures (list))
(ev-bn-run-all!)
{:failures ev-bn-failures :total (+ ev-bn-pass ev-bn-fail) :passed ev-bn-pass :failed ev-bn-fail})))

431
lib/events/tests/booking.sx Normal file
View File

@@ -0,0 +1,431 @@
;; lib/events/tests/booking.sx — capacity-safe booking, cancel, and holds.
(define ev-bk-pass 0)
(define ev-bk-fail 0)
(define ev-bk-failures (list))
(define
ev-bk-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-bk-pass (+ ev-bk-pass 1))
(do
(set! ev-bk-fail (+ ev-bk-fail 1))
(append!
ev-bk-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Take a consistent (roster, last-seq) snapshot of an occurrence's stream.
(define ev-bk-snap (fn (b k) (ev-booked-actors b k)))
(define ev-bk-seq (fn (b k) (persist/last-seq b (ev-booking-stream k))))
(define
ev-bk-run-all!
(fn
()
(do
(let
((b (persist/open)))
(do
(ev-bk-check!
"first booking takes seat 1"
(get (ev/book! b "o1" 3 (quote a)) :seat)
1)
(ev-bk-check!
"second booking takes seat 2"
(get (ev/book! b "o1" 3 (quote c)) :seat)
2)
(ev-bk-check!
"booked status reported"
(get (ev/book! b "o1" 3 (quote d)) :status)
:booked)
(ev-bk-check!
"roster is oldest-first"
(ev/roster b "o1")
(list (quote a) (quote c) (quote d)))
(ev-bk-check!
"seats-left is zero when full"
(ev/seats-left b "o1" 3)
0)
(ev-bk-check!
"free booking is confirmed state"
(ev/seat-state b "o1" (quote a))
:confirmed)))
(let
((b (persist/open)))
(do
(ev/book! b "o2" 1 (quote a))
(ev-bk-check!
"booking past capacity is refused"
(get (ev/book! b "o2" 1 (quote c)) :status)
:full)
(ev-bk-check!
"full does not grow the roster"
(ev/roster b "o2")
(list (quote a)))
(ev-bk-check!
"seats-left zero at capacity"
(ev/seats-left b "o2" 1)
0)))
(let
((b (persist/open)))
(do
(ev/book! b "o3" 5 (quote a))
(ev-bk-check!
"re-booking the same actor is idempotent"
(get (ev/book! b "o3" 5 (quote a)) :status)
:already)
(ev-bk-check!
"idempotent re-book reports existing seat"
(get (ev/book! b "o3" 5 (quote a)) :seat)
1)
(ev-bk-check!
"roster unchanged after re-book"
(ev/roster b "o3")
(list (quote a)))
(ev-bk-check!
"count unchanged after re-book"
(ev-booking-count b "o3")
1)))
(let
((b (persist/open)))
(do
(ev/book! b "last" 2 (quote x))
(let
((snap (ev-bk-snap b "last")) (exp (ev-bk-seq b "last")))
(let
((ra (ev/book-with-observed b "last" 2 (quote a) snap exp))
(rb
(ev/book-with-observed
b
"last"
2
(quote bee)
snap
exp)))
(do
(ev-bk-check!
"race winner is booked"
(get ra :status)
:booked)
(ev-bk-check!
"race winner takes the last seat"
(get ra :seat)
2)
(ev-bk-check!
"race loser is rejected with a conflict"
(get rb :status)
:conflict)
(ev-bk-check!
"conflict reports the advanced seq"
(get rb :actual)
(+ exp 1))
(ev-bk-check!
"no overbooking: exactly two on roster"
(ev-booking-count b "last")
2)
(ev-bk-check!
"race loser is NOT on the roster"
(ev-bk-member? (quote bee) (ev/roster b "last"))
false)
(ev-bk-check!
"race loser retrying gets full"
(get (ev/book! b "last" 2 (quote bee)) :status)
:full))))))
(let
((b (persist/open)))
(do
(ev/book! b "room" 3 (quote x))
(let
((snap (ev-bk-snap b "room")) (exp (ev-bk-seq b "room")))
(let
((ra (ev/book-with-observed b "room" 3 (quote a) snap exp))
(rb
(ev/book-with-observed
b
"room"
3
(quote bee)
snap
exp)))
(do
(ev-bk-check!
"room winner booked seat 2"
(get ra :seat)
2)
(ev-bk-check!
"room loser first conflicts"
(get rb :status)
:conflict)
(ev-bk-check!
"room loser retry books seat 3"
(get (ev/book! b "room" 3 (quote bee)) :seat)
3)
(ev-bk-check!
"room roster is x,a,bee"
(ev/roster b "room")
(list (quote x) (quote a) (quote bee)))
(ev-bk-check!
"room is now full"
(ev/seats-left b "room" 3)
0))))))
(let
((b (persist/open)))
(do
(ev/book! b "cx" 2 (quote a))
(ev/book! b "cx" 2 (quote c))
(ev-bk-check!
"occupied to capacity before cancel"
(ev/seats-left b "cx" 2)
0)
(ev-bk-check!
"booking when full (pre-cancel) is refused"
(get (ev/book! b "cx" 2 (quote d)) :status)
:full)
(ev-bk-check!
"cancel reports cancelled"
(get (ev/cancel! b "cx" (quote a)) :status)
:cancelled)
(ev-bk-check!
"cancel removes actor from roster"
(ev/roster b "cx")
(list (quote c)))
(ev-bk-check!
"cancel frees a seat"
(ev/seats-left b "cx" 2)
1)
(ev-bk-check!
"freed seat is bookable again"
(get (ev/book! b "cx" 2 (quote d)) :status)
:booked)
(ev-bk-check!
"roster after rebook is c,d"
(ev/roster b "cx")
(list (quote c) (quote d)))))
(let
((b (persist/open)))
(do
(ev/book! b "ce" 3 (quote a))
(ev-bk-check!
"cancelling an unbooked actor is a no-op"
(get (ev/cancel! b "ce" (quote z)) :status)
:not-booked)
(ev-bk-check!
"no-op cancel leaves roster intact"
(ev/roster b "ce")
(list (quote a)))
(ev/cancel! b "ce" (quote a))
(ev-bk-check!
"double cancel is not-booked the second time"
(get (ev/cancel! b "ce" (quote a)) :status)
:not-booked)
(ev-bk-check!
"empty roster after cancel"
(ev/roster b "ce")
(list))
(ev-bk-check!
"cancelled actor may re-book"
(get (ev/book! b "ce" 3 (quote a)) :status)
:booked)
(ev-bk-check!
"re-booked actor back on roster"
(ev/roster b "ce")
(list (quote a)))))
(let
((b (persist/open)))
(do
(ev/book! b "h" 2 (quote a))
(ev-bk-check!
"hold reports held"
(get (ev/hold! b "h" 2 (quote p)) :status)
:held)
(ev-bk-check!
"held seat is :held state"
(ev/seat-state b "h" (quote p))
:held)
(ev-bk-check!
"held actor is on the roster"
(ev/roster b "h")
(list (quote a) (quote p)))
(ev-bk-check!
"held seat blocks the last booking"
(get (ev/book! b "h" 2 (quote x)) :status)
:full)
(ev-bk-check!
"no seats left with one held"
(ev/seats-left b "h" 2)
0)))
(let
((b (persist/open)))
(do
(ev/hold! b "hc" 3 (quote p))
(ev-bk-check!
"confirm reports confirmed"
(get (ev/confirm! b "hc" (quote p)) :status)
:confirmed)
(ev-bk-check!
"confirmed seat is :confirmed state"
(ev/seat-state b "hc" (quote p))
:confirmed)
(ev-bk-check!
"re-confirm is already-confirmed"
(get (ev/confirm! b "hc" (quote p)) :status)
:already-confirmed)
(ev-bk-check!
"confirming a non-holder is not-held"
(get (ev/confirm! b "hc" (quote z)) :status)
:not-held)
(ev-bk-check!
"confirmed seat still occupies"
(ev/seats-left b "hc" 3)
2)))
(let
((b (persist/open)))
(do
(ev/book! b "hr" 2 (quote a))
(ev/hold! b "hr" 2 (quote p))
(ev-bk-check!
"full while hold pending"
(ev/seats-left b "hr" 2)
0)
(ev-bk-check!
"release reports released"
(get (ev/release! b "hr" (quote p)) :status)
:released)
(ev-bk-check!
"release frees the held seat"
(ev/seats-left b "hr" 2)
1)
(ev-bk-check!
"released actor off the roster"
(ev/roster b "hr")
(list (quote a)))
(ev-bk-check!
"freed seat bookable after release"
(get (ev/book! b "hr" 2 (quote x)) :status)
:booked)
(ev/hold! b "hr2" 1 (quote q))
(ev/confirm! b "hr2" (quote q))
(ev-bk-check!
"release on a confirmed seat is not-held"
(get (ev/release! b "hr2" (quote q)) :status)
:not-held)
(ev-bk-check!
"cancel frees a confirmed-from-hold seat"
(get (ev/cancel! b "hr2" (quote q)) :status)
:cancelled)))
(let
((b (persist/open)))
(do
(ev/book! b "hlast" 2 (quote x))
(let
((snap (ev-bk-snap b "hlast")) (exp (ev-bk-seq b "hlast")))
(let
((ra (ev/hold-with-observed b "hlast" 2 (quote p) snap exp))
(rb
(ev/hold-with-observed
b
"hlast"
2
(quote q)
snap
exp)))
(do
(ev-bk-check! "hold race winner held" (get ra :status) :held)
(ev-bk-check!
"hold race loser conflicts"
(get rb :status)
:conflict)
(ev-bk-check!
"no oversell via concurrent holds"
(ev-booking-count b "hlast")
2)
(ev-bk-check!
"hold loser retry gets full"
(get (ev/hold! b "hlast" 2 (quote q)) :status)
:full))))))
(let
((b (persist/open)))
(do
(ev/hold! b "hi" 4 (quote p))
(ev-bk-check!
"re-holding the same actor is idempotent"
(get (ev/hold! b "hi" 4 (quote p)) :status)
:already)
(ev-bk-check!
"hold idempotency keeps one seat"
(ev-booking-count b "hi")
1))))))
;; ---- waitlist ----
(define
ev-bk-wl-run-all!
(fn
()
(do
;; join the waitlist when full; book directly when a seat is free
(let
((b (persist/open)))
(do
(ev-bk-check! "waitlist! books when a seat is free" (get (ev/waitlist! b "w" 2 (quote a)) :status) :booked)
(ev-bk-check! "second booking still fits" (get (ev/waitlist! b "w" 2 (quote c)) :status) :booked)
(ev-bk-check! "third joins the waitlist when full" (get (ev/waitlist! b "w" 2 (quote x)) :status) :waitlisted)
(ev-bk-check! "fourth is next in line" (get (ev/waitlist! b "w" 2 (quote y)) :position) 2)
(ev-bk-check! "waitlist is FIFO" (ev/waitlist b "w") (list (quote x) (quote y)))
(ev-bk-check! "seats unaffected by waitlisting" (ev/roster b "w") (list (quote a) (quote c)))
(ev-bk-check! "waitlist-position reports a queued actor" (ev/waitlist-position b "w" (quote y)) 2)
(ev-bk-check! "waitlist-position 0 for a seated actor" (ev/waitlist-position b "w" (quote a)) 0)))
;; idempotency
(let
((b (persist/open)))
(do
(ev/waitlist! b "wi" 1 (quote a))
(ev/waitlist! b "wi" 1 (quote x))
(ev-bk-check! "re-joining as a seated actor is :already" (get (ev/waitlist! b "wi" 1 (quote a)) :status) :already)
(ev-bk-check! "re-joining the queue is :already-waiting" (get (ev/waitlist! b "wi" 1 (quote x)) :status) :already-waiting)
(ev-bk-check! "queue did not grow on re-join" (ev/waitlist b "wi") (list (quote x)))))
;; leaving the waitlist
(let
((b (persist/open)))
(do
(ev/waitlist! b "wl" 1 (quote a))
(ev/waitlist! b "wl" 1 (quote x))
(ev/waitlist! b "wl" 1 (quote y))
(ev-bk-check! "leave-waitlist reports left" (get (ev/leave-waitlist! b "wl" (quote x)) :status) :left)
(ev-bk-check! "leaving removes from the queue" (ev/waitlist b "wl") (list (quote y)))
(ev-bk-check! "leaving when not queued is not-waiting" (get (ev/leave-waitlist! b "wl" (quote z)) :status) :not-waiting)))
;; auto-promotion on cancel
(let
((b (persist/open)))
(do
(ev/waitlist! b "wp" 1 (quote a))
(ev/waitlist! b "wp" 1 (quote x))
(ev/waitlist! b "wp" 1 (quote y))
(let
((r (ev/cancel-promote! b "wp" 1 (quote a))))
(do
(ev-bk-check! "cancel-promote cancels the seat holder" (get r :status) :cancelled)
(ev-bk-check! "cancel-promote promotes the head of the queue" (get r :promoted) (quote x))))
(ev-bk-check! "promoted actor now holds the seat" (ev/roster b "wp") (list (quote x)))
(ev-bk-check! "promoted actor left the queue" (ev/waitlist b "wp") (list (quote y)))
(ev-bk-check! "promoted seat is confirmed" (ev/seat-state b "wp" (quote x)) :confirmed)
;; cancelling with an empty waitlist promotes nobody
(ev/leave-waitlist! b "wp" (quote y))
(let
((r2 (ev/cancel-promote! b "wp" 1 (quote x))))
(ev-bk-check! "cancel with empty waitlist promotes nobody" (get r2 :promoted) nil))
(ev-bk-check! "seat is free after the last cancel" (ev/seats-left b "wp" 1) 1))))))
(define
ev-booking-tests-run!
(fn
()
(do
(set! ev-bk-pass 0)
(set! ev-bk-fail 0)
(set! ev-bk-failures (list))
(ev-bk-run-all!)
(ev-bk-wl-run-all!)
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))

View File

@@ -0,0 +1,592 @@
;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion.
(define ev-cal-pass 0)
(define ev-cal-fail 0)
(define ev-cal-failures (list))
(define
ev-cal-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-cal-pass (+ ev-cal-pass 1))
(do
(set! ev-cal-fail (+ ev-cal-fail 1))
(append!
ev-cal-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Project occurrences to (civil weekday) pairs for legible assertions.
(define
ev-cal-shape
(fn
(occs)
(map
(fn
(o)
(list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start))))
occs)))
(define
ev-cal-starts
(fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs)))
(define
ev-cal-run-all!
(fn
()
(do
(ev-cal-check!
"epoch day zero"
(ev-days-from-civil 1970 1 1)
0)
(ev-cal-check!
"y2k day number"
(ev-days-from-civil 2000 1 1)
10957)
(ev-cal-check!
"leap day round trip"
(ev-civil-from-days
(ev-days-from-civil 2024 2 29))
(list 2024 2 29))
(ev-cal-check!
"pre-epoch round trip"
(ev-civil-from-days
(ev-days-from-civil 1969 12 31))
(list 1969 12 31))
(ev-cal-check!
"epoch is thursday"
(ev-weekday-of-days 0)
3)
(ev-cal-check!
"2026-06-06 is saturday"
(ev-dt-weekday (ev-date 2026 6 6))
5)
(ev-cal-check!
"dt carries time of day"
(ev-dt-tod
(ev-dt 2026 6 1 9 30))
570)
(ev-cal-check!
"civil from dt"
(ev-dt->civil
(ev-dt 2026 12 25 8 0))
(list 2026 12 25))
(ev-cal-check!
"days in feb 2024 (leap)"
(ev-days-in-month 2024 2)
29)
(ev-cal-check!
"days in feb 2026"
(ev-days-in-month 2026 2)
28)
(ev-cal-check!
"add months wraps year"
(ev-add-months 2026 11 3)
(list 2027 2))
(ev-cal-check!
"add months within year"
(ev-add-months 2026 1 5)
(list 2026 6))
(let
((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))
(do
(ev-cal-check!
"single inside window emits once"
(len
(ev-expand
ev
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
1)
(ev-cal-check!
"single before window omitted"
(len
(ev-expand
ev
(ev-date 2026 7 1)
(ev-date 2026 8 1)))
0)
(ev-cal-check!
"single after window omitted"
(len
(ev-expand
ev
(ev-date 2026 1 1)
(ev-date 2026 2 1)))
0)
(ev-cal-check!
"occurrence end is start plus duration"
(get
(first
(ev-expand
ev
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
:end)
(+
(ev-dt 2026 6 10 14 0)
60))))
(let
((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)))
(do
(ev-cal-check!
"daily count caps occurrences"
(ev-cal-starts
(ev-expand
daily
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 2)
(list 2026 6 3)
(list 2026 6 4)
(list 2026 6 5)))
(ev-cal-check!
"daily preserves time of day"
(ev-dt-tod
(get
(first
(ev-expand
daily
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
:start))
540)))
(let
((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1)))
(ev-cal-check!
"daily interval 3 steps by three days"
(ev-cal-starts
(ev-expand
di
(ev-date 2026 6 1)
(ev-date 2026 6 13)))
(list
(list 2026 6 1)
(list 2026 6 4)
(list 2026 6 7)
(list 2026 6 10)
(list 2026 6 13))))
(let
((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1)))
(ev-cal-check!
"count is window-independent (clip middle)"
(ev-cal-starts
(ev-expand
dc
(ev-date 2026 6 5)
(ev-date 2026 6 8)))
(list
(list 2026 6 5)
(list 2026 6 6)
(list 2026 6 7)
(list 2026 6 8))))
(let
((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1)))
(ev-cal-check!
"count exhausted before window yields nothing"
(len
(ev-expand
dc2
(ev-date 2026 6 10)
(ev-date 2026 6 20)))
0))
(let
((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1)))
(ev-cal-check!
"weekly byday mon/wed/fri first two weeks"
(ev-cal-shape
(ev-expand
wk
(ev-date 2026 6 1)
(ev-date 2026 6 13)))
(list
(list (list 2026 6 1) 0)
(list (list 2026 6 3) 2)
(list (list 2026 6 5) 4)
(list (list 2026 6 8) 0)
(list (list 2026 6 10) 2)
(list (list 2026 6 12) 4))))
(let
((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1)))
(ev-cal-check!
"weekly until clips trailing occurrences"
(ev-cal-starts
(ev-expand
wu
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 3)
(list 2026 6 8)
(list 2026 6 10))))
(let
((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1)))
(ev-cal-check!
"weekly interval 2 skips alternate weeks"
(ev-cal-starts
(ev-expand
wi
(ev-date 2026 6 1)
(ev-date 2026 7 6)))
(list
(list 2026 6 1)
(list 2026 6 15)
(list 2026 6 29))))
(let
((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1)))
(ev-cal-check!
"weekly default byday is dtstart weekday"
(ev-cal-shape
(ev-expand
wd
(ev-date 2026 6 1)
(ev-date 2026 8 1)))
(list
(list (list 2026 6 3) 2)
(list (list 2026 6 10) 2)
(list (list 2026 6 17) 2))))
(let
((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1)))
(ev-cal-check!
"weekly count window-independent (clip middle)"
(ev-cal-starts
(ev-expand
wc
(ev-date 2026 6 15)
(ev-date 2026 7 5)))
(list
(list 2026 6 15)
(list 2026 6 17)
(list 2026 6 22)
(list 2026 6 24)
(list 2026 6 29)
(list 2026 7 1))))
(let
((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1)))
(ev-cal-check!
"first week skips byday earlier than dtstart"
(ev-cal-starts
(ev-expand
wf
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 3)
(list 2026 6 5)
(list 2026 6 8)
(list 2026 6 10))))
(let
((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1)))
(do
(ev-cal-check!
"monthly bymonthday 15th"
(ev-cal-starts
(ev-expand
md
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list 2026 1 15)
(list 2026 2 15)
(list 2026 3 15)))
(ev-cal-check!
"monthly preserves time of day"
(ev-dt-tod
(get
(first
(ev-expand
md
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
:start))
540)))
(let
((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1)))
(ev-cal-check!
"monthly multiple bymonthday sorted within month"
(ev-cal-starts
(ev-expand
mm
(ev-date 2026 1 1)
(ev-date 2026 12 1)))
(list
(list 2026 1 1)
(list 2026 1 15)
(list 2026 2 1)
(list 2026 2 15))))
(let
((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1)))
(ev-cal-check!
"monthly bymonthday -1 is last day"
(ev-cal-starts
(ev-expand
ml
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list 2026 1 31)
(list 2026 2 28)
(list 2026 3 31))))
(let
((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)))
(ev-cal-check!
"monthly 2nd tuesday"
(ev-cal-shape
(ev-expand
mn
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list (list 2026 1 13) 1)
(list (list 2026 2 10) 1)
(list (list 2026 3 10) 1))))
(let
((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1)))
(ev-cal-check!
"monthly last friday"
(ev-cal-shape
(ev-expand
mz
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list (list 2026 1 30) 4)
(list (list 2026 2 27) 4)
(list (list 2026 3 27) 4))))
(let
((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1)))
(ev-cal-check!
"monthly default day-of-month skips short months"
(ev-cal-starts
(ev-expand
m31
(ev-date 2026 1 1)
(ev-date 2026 12 1)))
(list
(list 2026 1 31)
(list 2026 3 31)
(list 2026 5 31)
(list 2026 7 31))))
(let
((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1)))
(ev-cal-check!
"monthly interval 3 steps by quarter"
(ev-cal-starts
(ev-expand
mi
(ev-date 2026 1 1)
(ev-date 2027 1 1)))
(list
(list 2026 1 10)
(list 2026 4 10)
(list 2026 7 10))))
(let
((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1)))
(ev-cal-check!
"monthly count window-independent (clip middle)"
(ev-cal-starts
(ev-expand
mc
(ev-date 2026 4 1)
(ev-date 2026 6 30)))
(list
(list 2026 4 5)
(list 2026 5 5)
(list 2026 6 5))))
(let
((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1))
(b
(ev-event
(quote b)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 2}
1)))
(ev-cal-check!
"expand-all sorts merged occurrences by start"
(map
(fn (o) (list (get o :id) (ev-dt->civil (get o :start))))
(ev-expand-all
(list a b)
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list (quote b) (list 2026 6 1))
(list (quote b) (list 2026 6 2))
(list (quote a) (list 2026 6 2))
(list (quote a) (list 2026 6 3))))))))
;; ---- EXDATE / RDATE exceptions ----
(define
ev-cal-ex-run-all!
(fn
()
(do
;; EXDATE removes a matching occurrence from the recurrence
(let
((ex
(ev-event-full
(quote standup)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 5}
1
(list (ev-dt 2026 6 3 9 0))
(list))))
(ev-cal-check!
"EXDATE excludes the matching occurrence"
(ev-cal-starts (ev-expand ex (ev-date 2026 6 1) (ev-date 2026 7 1)))
(list (list 2026 6 1) (list 2026 6 2) (list 2026 6 4) (list 2026 6 5))))
;; EXDATE that matches nothing is a no-op
(let
((ex2
(ev-event-full
(quote s)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 3}
1
(list (ev-dt 2026 6 9 9 0))
(list))))
(ev-cal-check!
"EXDATE not matching any occurrence is a no-op"
(len (ev-expand ex2 (ev-date 2026 6 1) (ev-date 2026 7 1)))
3))
;; RDATE adds an explicit occurrence (within the window)
(let
((rd
(ev-event-full
(quote s)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 3}
1
(list)
(list (ev-dt 2026 6 10 9 0)))))
(do
(ev-cal-check!
"RDATE adds an explicit occurrence, sorted in"
(ev-cal-starts (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 7 1)))
(list (list 2026 6 1) (list 2026 6 2) (list 2026 6 3) (list 2026 6 10)))
(ev-cal-check!
"RDATE outside the window is dropped"
(len (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 6 5)))
3)))
;; RDATE coinciding with an rrule occurrence is de-duplicated
(let
((rdup
(ev-event-full
(quote s)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 3}
1
(list)
(list (ev-dt 2026 6 2 9 0)))))
(ev-cal-check!
"RDATE duplicating an occurrence does not double it"
(len (ev-expand rdup (ev-date 2026 6 1) (ev-date 2026 7 1)))
3))
;; EXDATE wins over RDATE for the same datetime
(let
((both
(ev-event-full
(quote s)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 3}
1
(list (ev-dt 2026 6 2 9 0))
(list (ev-dt 2026 6 2 9 0)))))
(ev-cal-check!
"EXDATE wins over RDATE and the rrule for the same date"
(ev-cal-starts (ev-expand both (ev-date 2026 6 1) (ev-date 2026 7 1)))
(list (list 2026 6 1) (list 2026 6 3))))
;; RDATE-only event (no rrule)
(let
((ronly
(ev-event-full
(quote s)
(ev-dt 2026 6 1 9 0)
30
nil
1
(list)
(list (ev-dt 2026 6 5 9 0) (ev-dt 2026 6 3 9 0)))))
(ev-cal-check!
"RDATE-only event yields dtstart plus the extra dates, sorted"
(ev-cal-starts (ev-expand ronly (ev-date 2026 6 1) (ev-date 2026 7 1)))
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 5))))
;; plain ev-event (no exception keys) is unaffected
(let
((plain (ev-event (quote p) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
(ev-cal-check!
"plain event without exceptions expands unchanged"
(len (ev-expand plain (ev-date 2026 6 1) (ev-date 2026 7 1)))
3)))))
;; ---- per-occurrence overrides (reschedule one instance) ----
(define
ev-cal-ov-run-all!
(fn
()
(let
((base (ev-event (quote standup) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 4} 1)))
(do
;; reschedule one instance to a new time + duration
(let
((moved (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 45)))
(let
((occs (ev-expand moved (ev-date 2026 6 1) (ev-date 2026 6 5))))
(do
(ev-cal-check!
"override moves only the targeted instance"
(map (fn (o) (ev-dt-tod (get o :start))) occs)
(list 540 840 540 540))
(ev-cal-check!
"override applies the new duration"
(map (fn (o) (- (get o :end) (get o :start))) occs)
(list 30 45 30 30))
(ev-cal-check!
"override keeps the series length"
(len occs)
4))))
;; an instance moved out of the window vacates its slot
(let
((movedout (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 20 9 0) 30)))
(ev-cal-check!
"instance moved out of window is dropped, slot vacated"
(ev-cal-starts (ev-expand movedout (ev-date 2026 6 1) (ev-date 2026 6 5)))
(list (list 2026 6 1) (list 2026 6 3) (list 2026 6 4))))
;; override for a non-existent original start is a no-op
(let
((noop (ev-with-override base (ev-dt 2026 6 9 9 0) (ev-dt 2026 6 9 14 0) 45)))
(ev-cal-check!
"override for a non-occurring start is a no-op"
(len (ev-expand noop (ev-date 2026 6 1) (ev-date 2026 6 5)))
4))
;; overrides re-sort the agenda when an instance moves earlier
(let
((early (ev-with-override base (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 1 7 0) 30)))
(ev-cal-check!
"an instance moved earlier re-sorts into place"
(map (fn (o) (ev-dt-tod (get o :start))) (ev-expand early (ev-date 2026 6 1) (ev-date 2026 6 5)))
(list 420 540 540 540)))))))
(define
ev-calendar-tests-run!
(fn
()
(do
(set! ev-cal-pass 0)
(set! ev-cal-fail 0)
(set! ev-cal-failures (list))
(ev-cal-run-all!)
(ev-cal-ex-run-all!)
(ev-cal-ov-run-all!)
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))

View File

@@ -0,0 +1,289 @@
;; lib/events/tests/federation.sx — trust-gated cross-instance agenda merge.
(define ev-fd-pass 0)
(define ev-fd-fail 0)
(define ev-fd-failures (list))
(define
ev-fd-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-fd-pass (+ ev-fd-pass 1))
(do
(set! ev-fd-fail (+ ev-fd-fail 1))
(append!
ev-fd-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Local schedule + two peers. Distinct start times make ordering legible.
(define
ev-fd-local
(fn
()
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 9 0)
60
nil
20)))
(define
ev-fd-berlin
(fn
()
(ev/peer
(quote berlin)
(ev/schedule
(ev/empty)
(quote meetup)
(ev-dt 2026 6 1 12 0)
90
nil
100))))
(define
ev-fd-paris
(fn
()
(ev/peer
(quote paris)
(ev/schedule
(ev/empty)
(quote salon)
(ev-dt 2026 6 1 15 0)
60
nil
30))))
(define
ev-fd-run-all!
(fn
()
(let
((local (ev-fd-local))
(peers (list (ev-fd-berlin) (ev-fd-paris)))
(ws (ev-date 2026 6 1))
(we (ev-date 2026 6 2)))
(do
(ev-fd-check!
"trusts a peer in the trust set"
(ev/trusts? (list (quote berlin)) (quote berlin))
true)
(ev-fd-check!
"does not trust a peer outside the set"
(ev/trusts? (list (quote berlin)) (quote paris))
false)
(ev-fd-check!
"trusted-peers filters to the trust set"
(map ev/peer-id (ev/trusted-peers peers (list (quote berlin))))
(list (quote berlin)))
(let
((fed (ev/federated-agenda local peers (list (quote berlin)) ws we)))
(do
(ev-fd-check!
"merge includes local + trusted peer only"
(map (fn (o) (list (get o :origin) (get o :id))) fed)
(list
(list :local (quote yoga))
(list (quote berlin) (quote meetup))))
(ev-fd-check!
"merge is sorted by start"
(map (fn (o) (get o :start)) fed)
(list
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 12 0)))
(ev-fd-check!
"untrusted peer (paris) contributes nothing"
(len (ev/from-origin fed (quote paris)))
0)
(ev-fd-check!
"local occurrences tagged :local"
(map (fn (o) (get o :id)) (ev/from-origin fed :local))
(list (quote yoga)))
(ev-fd-check!
"peer occurrences tagged with the peer id"
(map
(fn (o) (get o :id))
(ev/from-origin fed (quote berlin)))
(list (quote meetup)))))
(let
((fed2 (ev/federated-agenda local peers (list (quote berlin) (quote paris)) ws we)))
(ev-fd-check!
"trusting both peers merges all three, sorted"
(map (fn (o) (list (get o :origin) (get o :id))) fed2)
(list
(list :local (quote yoga))
(list (quote berlin) (quote meetup))
(list (quote paris) (quote salon)))))
(let
((fed3 (ev/federated-agenda local peers (list) ws we)))
(do
(ev-fd-check!
"empty trust yields only local occurrences"
(map (fn (o) (get o :origin)) fed3)
(list :local))
(ev-fd-check!
"empty trust still includes local"
(len fed3)
1)))
(let
((rpeer (ev/peer (quote tokyo) (ev/schedule (ev/empty) (quote standup) (ev-dt 2026 6 1 8 0) 15 {:freq :daily :count 3} 5))))
(let
((pa (ev/peer-agenda rpeer ws (ev-date 2026 6 4))))
(do
(ev-fd-check!
"peer recurrence expands in the window"
(len pa)
3)
(ev-fd-check!
"every peer occurrence is tagged with the peer id"
(map (fn (o) (get o :origin)) pa)
(list (quote tokyo) (quote tokyo) (quote tokyo))))))))))
;; ---- federated free/busy ----
(define
ev-fd-fb-run-all!
(fn
()
(let
((local-db
(ev-avail-db
(list (ev-occ (quote yoga) (ev-dt 2026 6 1 9 0) 60))
(list (list (quote nia) (str (quote yoga) "@" (ev-dt 2026 6 1 9 0))))))
(berlin
(ev/peer-with-busy
(quote berlin)
(ev/empty)
(list
(list (quote nia)
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0)))))))
(paris
(ev/peer-with-busy
(quote paris)
(ev/empty)
(list
(list (quote nia)
(list (list (ev-dt 2026 6 1 11 0) (ev-dt 2026 6 1 12 0))))))))
(let
((peers (list berlin paris)))
(do
;; peer-busy reads a peer's published intervals
(ev-fd-check!
"peer-busy returns published intervals for an actor"
(ev/peer-busy berlin (quote nia))
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
(ev-fd-check!
"peer-busy empty for an actor with nothing published"
(ev/peer-busy berlin (quote zed))
(list))
;; federated-busy unions local + trusted-peer busy, sorted
(ev-fd-check!
"federated-busy unions local + trusted peer, sorted"
(ev/federated-busy local-db (list berlin) (list (quote berlin)) (quote nia))
(list
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
(ev-fd-check!
"untrusted peer busy is excluded from federated-busy"
(ev/federated-busy local-db peers (list (quote berlin)) (quote nia))
(list
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
;; federated-free? considers both local and trusted-peer commitments
(ev-fd-check!
"free locally and on peers in an open window"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 16 0) (ev-dt 2026 6 1 17 0))
true)
(ev-fd-check!
"not free during a LOCAL booking"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 9 30) (ev-dt 2026 6 1 9 45))
false)
(ev-fd-check!
"not free during a TRUSTED PEER busy interval"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 14 30) (ev-dt 2026 6 1 14 45))
false)
(ev-fd-check!
"free during an UNTRUSTED peer's busy interval (paris not trusted)"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
true)
(ev-fd-check!
"not free once paris is trusted too"
(ev/federated-free? local-db peers (list (quote berlin) (quote paris)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
false)
(ev-fd-check!
"federated-free? half-open at a busy edge"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 15 0) (ev-dt 2026 6 1 16 0))
true))))))
;; ---- injected transport (fed-sx) ----
(define
ev-fd-tx-run-all!
(fn
()
(let
((local (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 9 0) 60 nil 20))
(berlin (ev/peer (quote berlin) (ev/schedule (ev/empty) (quote meetup) (ev-dt 2026 6 1 12 0) 90 nil 100)))
(ws (ev-date 2026 6 1))
(we (ev-date 2026 6 2)))
(let
((fetch (ev/peer-fetch (list berlin))))
(do
;; in-process adapter merges through the transport interface
(ev-fd-check!
"federated-agenda-via merges local + fetched peer"
(map (fn (o) (list (get o :origin) (get o :id)))
(ev/federated-agenda-via local (list (quote berlin)) ws we fetch))
(list (list :local (quote yoga)) (list (quote berlin) (quote meetup))))
;; an unreachable / unknown peer degrades gracefully
(ev-fd-check!
"an unreachable peer is skipped, agenda still served"
(map (fn (o) (get o :origin))
(ev/federated-agenda-via local (list (quote berlin) (quote ghost)) ws we fetch))
(list :local (quote berlin)))
;; reachability report
(ev-fd-check!
"federation-status reports per-peer reachability"
(ev/federation-status (list (quote berlin) (quote ghost)) ws we fetch)
(list (list (quote berlin) :ok) (list (quote ghost) :error)))
;; an explicit remote transport (returns occurrences directly)
(let
((remote-fetch
(fn
(pid rws rwe)
(if (= pid (quote tokyo))
{:status :ok
:occurrences (list (ev-occ (quote standup) (ev-dt 2026 6 1 8 0) 15))}
{:status :error :reason :unreachable}))))
(do
(ev-fd-check!
"a remote transport's occurrences merge with origin tags"
(map (fn (o) (list (get o :origin) (get o :id)))
(ev/federated-agenda-via local (list (quote tokyo)) ws we remote-fetch))
(list (list (quote tokyo) (quote standup)) (list :local (quote yoga))))
(ev-fd-check!
"remote transport error degrades to local only"
(map (fn (o) (get o :origin))
(ev/federated-agenda-via local (list (quote osaka)) ws we remote-fetch))
(list :local))))
;; no trusted peers -> only local
(ev-fd-check!
"no trusted peer ids yields only local"
(map (fn (o) (get o :origin))
(ev/federated-agenda-via local (list) ws we fetch))
(list :local)))))))
(define
ev-federation-tests-run!
(fn
()
(do
(set! ev-fd-pass 0)
(set! ev-fd-fail 0)
(set! ev-fd-failures (list))
(ev-fd-run-all!)
(ev-fd-fb-run-all!)
(ev-fd-tx-run-all!)
{:failures ev-fd-failures :total (+ ev-fd-pass ev-fd-fail) :passed ev-fd-pass :failed ev-fd-fail})))

404
lib/events/tests/ical.sx Normal file
View File

@@ -0,0 +1,404 @@
;; lib/events/tests/ical.sx — iCalendar (RFC 5545) export.
(define ev-ic-pass 0)
(define ev-ic-fail 0)
(define ev-ic-failures (list))
(define
ev-ic-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-ic-pass (+ ev-ic-pass 1))
(do
(set! ev-ic-fail (+ ev-ic-fail 1))
(append!
ev-ic-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Find the value of a "KEY:value" line in a VEVENT line list (or nil).
(define
ev-ic-line
(fn
(lines key)
(cond
((empty? lines) nil)
((ev-ic-prefix? (first lines) (str key ":")) (first lines))
(else (ev-ic-line (rest lines) key)))))
(define
ev-ic-prefix?
(fn
(s p)
(and (>= (len s) (len p)) (= (substring s 0 (len p)) p))))
(define
ev-ic-run-all!
(fn
()
(do
(let
((lines (ev/event->ical-lines (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1))))
(do
(ev-ic-check! "VEVENT opens" (first lines) "BEGIN:VEVENT")
(ev-ic-check! "VEVENT closes" (ev-ic-line lines "END") "END:VEVENT")
(ev-ic-check!
"UID is the event id"
(ev-ic-line lines "UID")
"UID:one")
(ev-ic-check!
"DTSTART is a UTC basic-format stamp"
(ev-ic-line lines "DTSTART")
"DTSTART:20260610T140000Z")
(ev-ic-check!
"DURATION of 60m is PT1H"
(ev-ic-line lines "DURATION")
"DURATION:PT1H")
(ev-ic-check!
"a one-off event has no RRULE"
(ev-ic-line lines "RRULE")
nil)))
(ev-ic-check!
"30m duration is PT30M"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote e)
(ev-dt 2026 1 1 9 0)
30
nil
1))
"DURATION")
"DURATION:PT30M")
(ev-ic-check!
"90m duration is PT1H30M"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote e)
(ev-dt 2026 1 1 9 0)
90
nil
1))
"DURATION")
"DURATION:PT1H30M")
(let
((lines (ev/event->ical-lines (ev-event-full (quote yoga) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :until (ev-dt 2026 6 30 23 0) :byday (list 0 2)} 20 (list (ev-dt 2026 6 8 18 0)) (list (ev-dt 2026 6 20 18 0))))))
(do
(ev-ic-check!
"weekly RRULE serializes interval/until/byday in order"
(ev-ic-line lines "RRULE")
"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T230000Z;BYDAY=MO,WE")
(ev-ic-check!
"EXDATE line"
(ev-ic-line lines "EXDATE")
"EXDATE:20260608T180000Z")
(ev-ic-check!
"RDATE line"
(ev-ic-line lines "RDATE")
"RDATE:20260620T180000Z")))
(ev-ic-check!
"daily COUNT RRULE"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote d)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 5}
1))
"RRULE")
"RRULE:FREQ=DAILY;COUNT=5")
(ev-ic-check!
"monthly nth-weekday BYDAY (2nd Tuesday)"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 13 9 0)
60
{:freq :monthly :byday (list {:ord 2 :wd 1})}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYDAY=2TU")
(ev-ic-check!
"monthly last-Friday BYDAY"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 30 9 0)
60
{:freq :monthly :byday (list {:ord -1 :wd 4})}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYDAY=-1FR")
(ev-ic-check!
"monthly BYMONTHDAY (incl. negative)"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 15 9 0)
60
{:bymonthday (list 15 -1) :freq :monthly}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1")
(ev-ic-check!
"all seven weekday tokens map correctly"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote w)
(ev-dt 2026 6 1 9 0)
30
{:freq :weekly :byday (list 0 1 2 3 4 5 6)}
1))
"RRULE")
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU")
(let
((cal (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 nil 1) (ev-event (quote b) (ev-dt 2026 6 2 9 0) 30 nil 1)))))
(do
(ev-ic-check! "VCALENDAR opens" (first cal) "BEGIN:VCALENDAR")
(ev-ic-check!
"VCALENDAR declares VERSION"
(ev-ic-line cal "VERSION")
"VERSION:2.0")
(ev-ic-check!
"two events -> two VEVENT blocks"
(len (filter (fn (l) (= l "BEGIN:VEVENT")) cal))
2)
(ev-ic-check!
"VCALENDAR has exactly one closing line"
(len (filter (fn (l) (= l "END:VCALENDAR")) cal))
1)))
(ev-ic-check!
"render joins lines with CRLF"
(ev/ical-render
(list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR"))
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"))))
;; ---- import + round-trip ----
;; The occurrence starts an event expands to over a fixed window.
(define
ev-ic-starts
(fn
(ev)
(map (fn (o) (get o :start)) (ev-expand ev (ev-date 2026 1 1) (ev-date 2027 1 1)))))
;; Round-trip an event through export then import; true if both expand alike.
(define
ev-ic-roundtrips?
(fn
(ev)
(= (ev-ic-starts ev) (ev-ic-starts (ev/ical-lines->event (ev/event->ical-lines ev))))))
(define
ev-ic-rt-run-all!
(fn
()
(do
;; ---- field parsers ----
(ev-ic-check! "parse DTSTART" (ev-ical-parse-dt "20260601T180000Z") (ev-dt 2026 6 1 18 0))
(ev-ic-check! "parse DURATION PT1H30M" (ev-ical-parse-duration "PT1H30M") 90)
(ev-ic-check! "parse DURATION PT1H" (ev-ical-parse-duration "PT1H") 60)
(ev-ic-check! "parse DURATION PT30M" (ev-ical-parse-duration "PT30M") 30)
(ev-ic-check! "parse plain BYDAY token" (ev-ical-parse-byday-token "MO") 0)
(ev-ic-check! "parse ordinal BYDAY token" (ev-ical-parse-byday-token "2TU") {:ord 2 :wd 1})
(ev-ic-check! "parse last-weekday BYDAY token" (ev-ical-parse-byday-token "-1FR") {:ord -1 :wd 4})
;; ---- imported event basic fields ----
(let
((ev (ev/ical-lines->event (ev/event->ical-lines (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 90 nil 1)))))
(do
(ev-ic-check! "imported id is a symbol" (get ev :id) (quote yoga))
(ev-ic-check! "imported dtstart" (get ev :dtstart) (ev-dt 2026 6 1 18 0))
(ev-ic-check! "imported duration" (get ev :duration) 90)))
;; ---- round-trips preserve the occurrence set ----
(ev-ic-check!
"round-trip: one-off event"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 10 14 0) 60 nil 1))
true)
(ev-ic-check!
"round-trip: daily COUNT"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1))
true)
(ev-ic-check!
"round-trip: weekly interval/until/byday + exdate + rdate"
(ev-ic-roundtrips?
(ev-event-full
(quote a)
(ev-dt 2026 6 1 18 0)
90
{:freq :weekly :interval 2 :byday (list 0 2) :until (ev-dt 2026 6 30 23 0)}
20
(list (ev-dt 2026 6 8 18 0))
(list (ev-dt 2026 6 20 18 0))))
true)
(ev-ic-check!
"round-trip: monthly nth-weekday"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 13 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1))
true)
(ev-ic-check!
"round-trip: monthly bymonthday"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 15 9 0) 60 {:freq :monthly :bymonthday (list 15 -1)} 1))
true)
;; ---- parse a VCALENDAR with several events ----
(let
((cal
(ev/events->ical-lines
(list
(ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)
(ev-event (quote b) (ev-dt 2026 6 2 10 0) 60 nil 1)))))
(let
((events (ev/parse-vcalendar cal)))
(do
(ev-ic-check! "VCALENDAR parses both events" (len events) 2)
(ev-ic-check! "first event id" (get (first events) :id) (quote a))
(ev-ic-check! "second event id" (get (first (rest events)) :id) (quote b))
(ev-ic-check!
"parsed events expand correctly"
(ev-ic-starts (first events))
(ev-ic-starts (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))))))))
;; ---- timezone-aware export (TZID + VTIMEZONE) ----
(define
ev-ic-find
(fn
(lines pfx)
(cond
((empty? lines) nil)
((ev-ic-prefix? (first lines) pfx) (first lines))
(else (ev-ic-find (rest lines) pfx)))))
(define ev-ic-count (fn (lines x) (len (filter (fn (l) (= l x)) lines))))
(define
ev-ic-index
(fn
(lines x)
(cond
((empty? lines) -1)
((= (first lines) x) 0)
(else
(let ((r (ev-ic-index (rest lines) x))) (if (< r 0) -1 (+ 1 r)))))))
(define
ev-ic-tz-run-all!
(fn
()
(do
;; a tz event's DTSTART is local wall-clock with a TZID parameter
(ev-ic-check!
"tz event DTSTART uses TZID + local wall-clock (not UTC)"
(ev-ic-find (ev/event->ical-lines (ev-event-tz (quote w) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)) "DTSTART")
"DTSTART;TZID=Europe/London:20260715T180000")
(ev-ic-check!
"a non-tz event still uses a UTC Z stamp"
(ev-ic-find (ev/event->ical-lines (ev-event (quote n) (ev-dt 2026 7 15 18 0) 60 nil 1)) "DTSTART")
"DTSTART:20260715T180000Z")
;; UNTIL stays UTC even for a TZID event (RFC 5545)
(ev-ic-check!
"tz event RRULE UNTIL is still UTC"
(ev-ic-find
(ev/event->ical-lines
(ev-event-tz (quote s) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :byday (list 0) :until (ev-dt 2026 6 30 23 0)} 1 ev-tz-london))
"RRULE")
"RRULE:FREQ=WEEKLY;UNTIL=20260630T220000Z;BYDAY=MO")
;; EXDATE matches the DTSTART form (TZID + local)
(ev-ic-check!
"tz event EXDATE uses TZID + local"
(ev-ic-find
(ev/event->ical-lines
(assoc
(ev-event-tz (quote s) (ev-dt 2026 7 1 18 0) 60 {:freq :daily :count 3} 1 ev-tz-london)
:exdate
(list (ev-dt 2026 7 2 18 0))))
"EXDATE")
"EXDATE;TZID=Europe/London:20260702T180000")
;; ---- VTIMEZONE block ----
(let
((vtz (ev-ical-vtimezone ev-tz-london)))
(do
(ev-ic-check! "VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Europe/London")
(ev-ic-check! "DAYLIGHT transitions GMT->BST" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")
(ev-ic-check! "DAYLIGHT rule is last Sunday of March" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=3") "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU")
(ev-ic-check! "STANDARD rule is last Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU")))
(let
((vtz (ev-ical-vtimezone ev-tz-paris)))
(do
(ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200")
(ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")))
;; southern hemisphere exports a valid VTIMEZONE too: reversed offsets,
;; first-Sunday rules, and the -480 rule time folds back to local 02:00/03:00
(let
((vtz (ev-ical-vtimezone ev-tz-sydney)))
(do
(ev-ic-check! "Sydney VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Australia/Sydney")
(ev-ic-check! "Sydney DAYLIGHT goes to +1100 (AEDT)" (ev-ic-find vtz "TZOFFSETTO:+1100") "TZOFFSETTO:+1100")
(ev-ic-check! "Sydney STANDARD goes to +1000 (AEST)" (ev-ic-find vtz "TZOFFSETTO:+1000") "TZOFFSETTO:+1000")
(ev-ic-check! "Sydney DAYLIGHT rule is first Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU")
(ev-ic-check! "Sydney STANDARD rule is first Sunday of April" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=4") "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU")
(ev-ic-check! "Sydney DAYLIGHT begins 02:00 local (AEST std, -480 folded)" (ev-ic-find vtz "DTSTART") "DTSTART:19701004T020000")))
;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ----
(let
((cal (ev/events->ical-lines (list (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)))))
(do
(ev-ic-check! "VCALENDAR includes the referenced VTIMEZONE" (ev-ic-count cal "BEGIN:VTIMEZONE") 1)
(ev-ic-check! "VTIMEZONE precedes the VEVENT" (< (ev-ic-index cal "BEGIN:VTIMEZONE") (ev-ic-index cal "BEGIN:VEVENT")) true)))
(ev-ic-check!
"two events in the same zone share one VTIMEZONE"
(ev-ic-count
(ev/events->ical-lines
(list
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-london)))
"BEGIN:VTIMEZONE")
1)
(ev-ic-check!
"events in two zones get two VTIMEZONEs"
(ev-ic-count
(ev/events->ical-lines
(list
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-paris)))
"BEGIN:VTIMEZONE")
2)
(ev-ic-check!
"a non-tz-only calendar has no VTIMEZONE"
(ev-ic-count (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1))) "BEGIN:VTIMEZONE")
0)
;; ---- import tolerates the TZID parameter ----
(ev-ic-check!
"import parses DTSTART;TZID local time"
(get
(ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)))
:dtstart)
(ev-dt 2026 7 15 18 0))
(ev-ic-check!
"import parses a southern-zone DTSTART;TZID local time"
(get
(ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-sydney)))
:dtstart)
(ev-dt 2026 1 15 18 0)))))
(define
ev-ical-tests-run!
(fn
()
(do
(set! ev-ic-pass 0)
(set! ev-ic-fail 0)
(set! ev-ic-failures (list))
(ev-ic-run-all!)
(ev-ic-rt-run-all!)
(ev-ic-tz-run-all!)
{:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail})))

View File

@@ -0,0 +1,144 @@
;; lib/events/tests/integration.sx — end-to-end pipeline: derive notification
;; messages (SX) -> deliver them through the durable notify flow (Scheme).
(define ev-it-pass 0)
(define ev-it-fail 0)
(define ev-it-failures (list))
(define
ev-it-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-it-pass (+ ev-it-pass 1))
(do
(set! ev-it-fail (+ ev-it-fail 1))
(append!
ev-it-failures
(str name "\n expected: " expected "\n got: " got))))))
(define ev-it-status (fn (outcome) (first outcome)))
(define ev-it-id (fn (outcome) (first (rest outcome))))
;; A store with a weekly class; nia + ola booked into the first occurrence.
(define
ev-it-setup
(fn
(b)
(let
((store (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :count 4 :byday (list 0 2)} 20)))
(let
((occ1 (ev-occ (quote yoga) (ev-dt 2026 6 1 18 0) 60)))
(do
(ev/book-occ! b store (quote nia) occ1)
(ev/book-occ! b store (quote ola) occ1)
store)))))
(define
ev-it-run-all!
(fn
()
(do
(let
((b (persist/open)))
(let
((store (ev-it-setup b)))
(let
((reminders (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
(let
((msgs (map ev/reminder->msg reminders))
(outcomes
(ev/deliver-messages
(map ev/reminder->msg reminders)
ev-notify-ok-transport
3
20)))
(do
(ev-it-check!
"every booked attendee's reminder is delivered"
(map ev-it-status outcomes)
(list "delivered" "delivered"))
(ev-it-check!
"one delivery per derived reminder"
(len outcomes)
(len msgs))
(ev-it-check!
"delivered ids match the reminder idempotency keys"
(map ev-it-id outcomes)
(map (fn (r) (get r :id)) reminders)))))))
(let
((b (persist/open)))
(let
((store (ev-it-setup b)))
(let
((msgs (map ev/reminder->msg (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))))
(ev-it-check!
"a permanently-failing transport reports failed deliveries"
(map
ev-it-status
(ev/deliver-messages
msgs
"(lambda (k p) (list (quote retry) (quote down)))"
2
20))
(list "failed" "failed")))))
(let
((b (persist/open)))
(do
(ev/book! b "occ" 1 (quote nia))
(ev/waitlist! b "occ" 1 (quote ola))
(ev/cancel-promote! b "occ" 1 (quote nia))
(let
((promoted (ev/notify-of-kind (ev/booking-notifications b "occ" (quote yoga)) :promoted)))
(let
((outcomes (ev/deliver-messages (map ev/booking-notify->msg promoted) ev-notify-ok-transport 3 12)))
(do
(ev-it-check!
"the waitlist-promotion notification is delivered"
(map ev-it-status outcomes)
(list "delivered"))
(ev-it-check!
"exactly one promotion was delivered"
(len outcomes)
1))))))
(let
((b (persist/open)))
(let
((ev (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 60 {:freq :daily :count 3} 20)))
(do
(ev/book-occ!
b
(ev/add-event (ev/empty) ev)
(quote nia)
(ev-occ
(quote yoga)
(ev-dt 2026 6 2 18 0)
60))
(let
((moved (ev-with-override ev (ev-dt 2026 6 2 18 0) (ev-dt 2026 6 2 20 0) 60)))
(let
((outcomes (ev/deliver-messages (map ev/reschedule-notify->msg (ev/reschedule-notifications b moved)) ev-notify-ok-transport 3 12)))
(ev-it-check!
"the reschedule notice is delivered to the booked attendee"
(map ev-it-status outcomes)
(list "delivered")))))))
(ev-it-check!
"delivering no messages yields no outcomes"
(ev/deliver-messages
(list)
ev-notify-ok-transport
3
12)
(list)))))
(define
ev-integration-tests-run!
(fn
()
(do
(set! ev-it-pass 0)
(set! ev-it-fail 0)
(set! ev-it-failures (list))
(ev-it-run-all!)
{:failures ev-it-failures :total (+ ev-it-pass ev-it-fail) :passed ev-it-pass :failed ev-it-fail})))

View File

@@ -0,0 +1,77 @@
;; lib/events/tests/notify.sx — durable notification delivery flows.
(define ev-nt-pass 0)
(define ev-nt-fail 0)
(define ev-nt-failures (list))
(define
ev-nt-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-nt-pass (+ ev-nt-pass 1))
(do
(set! ev-nt-fail (+ ev-nt-fail 1))
(append!
ev-nt-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Each case runs a Scheme flow program (notify flows preloaded) and asserts on
;; the SX-native result. Scheme symbols come back as strings.
(define
ev-nt-run-all!
(fn
()
(do
(ev-nt-check!
"reminder delivers on the first attempt"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote alice) (quote hello))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 5)\n (list (flow/status (car (cdr s))) (flow/result (car (cdr s))))")
(list "done" (list "delivered" "m1" 1)))
(ev-nt-check!
"reminder retries a transient failure then delivers"
(ev/notify-run
"(define hits 0)\n (define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote bob) (quote hi))))\n (flow-run-host (lambda (k p) (begin (set! hits (+ hits 1)) (if (< hits 2) (list (quote retry) (quote down)) (list (quote ok) (quote sent))))) 10)\n (list (flow/result (car (cdr s))) hits)")
(list (list "delivered" "m1" 2) 2))
(ev-nt-check!
"reminder gives up after maxn attempts"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 2) (list (quote m1) (quote x) (quote y))))\n (flow-run-host (lambda (k p) (list (quote retry) (quote down))) 10)\n (flow/result (car (cdr s)))")
(list "failed" "m1" "down"))
(ev-nt-check!
"redelivery of the same id sends only once (at-least-once, idempotent)"
(ev/notify-run
"(define sent (list)) (define deliveries 0)\n (define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (ev-mem id sent)\n (list (quote ok) (quote duplicate))\n (begin (set! sent (cons id sent)) (set! deliveries (+ deliveries 1)) (list (quote ok) (quote sent))))))\n (define s1 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (define s2 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (list deliveries (flow/result (car (cdr s2))))")
(list 1 (list "delivered" "m1" 1)))
(ev-nt-check!
"digest delivers every message in the batch"
(ev/notify-run
"(define s (flow/start (ev-deliver-digest 3) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 10)\n (flow/result (car (cdr s)))")
(list
(list "delivered" "a" 1)
(list "delivered" "b" 1)))
(ev-nt-check!
"digest reports per-message outcomes independently"
(ev/notify-run
"(define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (equal? id (quote b)) (list (quote retry) (quote flaky)) (list (quote ok) (quote sent)))))\n (define s (flow/start (ev-deliver-digest 2) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)) (list (quote c) (quote u3) (quote ya)))))\n (flow-run-host xport 12)\n (flow/result (car (cdr s)))")
(list
(list "delivered" "a" 1)
(list "failed" "b" "flaky")
(list "delivered" "c" 1)))
(ev-nt-check!
"delivery suspends until the transport responds"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow/status (car (cdr s)))")
"suspended"))))
(define
ev-notify-tests-run!
(fn
()
(do
(set! ev-nt-pass 0)
(set! ev-nt-fail 0)
(set! ev-nt-failures (list))
(ev-nt-run-all!)
{:failures ev-nt-failures :total (+ ev-nt-pass ev-nt-fail) :passed ev-nt-pass :failed ev-nt-fail})))

View File

@@ -0,0 +1,276 @@
;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda.
(define ev-rm-pass 0)
(define ev-rm-fail 0)
(define ev-rm-failures (list))
(define
ev-rm-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-rm-pass (+ ev-rm-pass 1))
(do
(set! ev-rm-fail (+ ev-rm-fail 1))
(append!
ev-rm-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off
;; talk; durable bookings on a persist backend.
(define
ev-rm-store
(fn
()
(ev/schedule
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 18 0)
60
{:freq :weekly :count 4 :byday (list 0 2)}
20)
(quote talk)
(ev-dt 2026 6 2 12 0)
30
nil
50)))
(define
ev-rm-run-all!
(fn
()
(let
((store (ev-rm-store)) (b (persist/open)))
(let
((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1))))
(do
(ev/book-occ! b store (quote nia) (first occs))
(ev/book-occ! b store (quote ola) (first occs))
(ev/book-occ!
b
store
(quote ola)
(ev-occ
(quote talk)
(ev-dt 2026 6 2 12 0)
30))
(do
(let
((rs (ev/occurrence-reminders b (first occs) 60)))
(do
(ev-rm-check!
"one reminder per booked attendee"
(len rs)
2)
(ev-rm-check!
"reminder fires lead minutes before start"
(get (first rs) :fire-at)
(-
(ev-dt
2026
6
1
18
0)
60))
(ev-rm-check!
"reminder idempotency key encodes occ/recipient/lead"
(get (first rs) :id)
(str
(ev-occ-key (first occs))
"/"
(quote nia)
"/"
60))
(ev-rm-check!
"reminder names the event"
(get (first rs) :event)
(quote yoga))))
(ev-rm-check!
"unbooked occurrence has no reminders"
(len
(ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60))
0)
(let
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
(do
(ev-rm-check!
"agenda reminders cover all bookings"
(len all)
3)
(ev-rm-check!
"agenda reminders sorted by fire-at"
(map (fn (r) (get r :fire-at)) all)
(list
(-
(ev-dt
2026
6
1
18
0)
60)
(-
(ev-dt
2026
6
1
18
0)
60)
(-
(ev-dt
2026
6
2
12
0)
60)))))
(let
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
(do
(ev-rm-check!
"nothing due before the first fire-at"
(len
(ev/due-reminders
all
(-
(ev-dt
2026
6
1
17
0)
1)))
0)
(ev-rm-check!
"the two yoga reminders are due at 17:00"
(len
(ev/due-reminders
all
(ev-dt
2026
6
1
17
0)))
2)
(ev-rm-check!
"all reminders due once past the last fire-at"
(len
(ev/due-reminders
all
(ev-dt
2026
6
2
12
0)))
3)))
(let
((r (first (ev/occurrence-reminders b (first occs) 60))))
(ev-rm-check!
"reminder projects to (id recipient body)"
(ev/reminder->msg r)
(list
(str
(ev-occ-key (first occs))
"/"
(quote nia)
"/"
60)
(quote nia)
(list
:reminder (quote yoga)
(ev-dt
2026
6
1
18
0)))))
(let
((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(do
(ev-rm-check!
"digest is addressed to the actor"
(get dig :recipient)
(quote ola))
(ev-rm-check!
"digest lists the actor's booked occurrences"
(map (fn (it) (get it :event)) (get dig :items))
(list (quote yoga) (quote talk)))))
(let
((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(ev-rm-check!
"digest empty for an actor with no bookings"
(get empty-dig :items)
(list)))))))))
;; ---- reschedule notifications ----
(define
ev-rm-rs-run-all!
(fn
()
(let
((b (persist/open))
(ev (ev-event (quote yoga) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 20)))
(let
((occ2 (ev-occ (quote yoga) (ev-dt 2026 6 2 9 0) 60)))
(do
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote nia) occ2)
(ev/book-occ! b (ev/add-event (ev/empty) ev) (quote ola) occ2)
;; reschedule the Jun 2 occurrence to 14:00 / 90 min
(let
((moved (ev-with-override ev (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 90)))
(let
((ns (ev/reschedule-notifications b moved)))
(do
(ev-rm-check!
"every booked attendee is notified of the reschedule"
(map (fn (n) (get n :recipient)) ns)
(list (quote nia) (quote ola)))
(ev-rm-check!
"reschedule carries old and new start"
(list (get (first ns) :old-start) (get (first ns) :new-start))
(list (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))
(ev-rm-check!
"reschedule carries the new duration"
(get (first ns) :new-duration)
90)
(ev-rm-check!
"reschedule idempotency key encodes original key + new start"
(get (first ns) :id)
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0)))
(ev-rm-check!
"reschedule projects to notify wire shape"
(ev/reschedule-notify->msg (first ns))
(list
(str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0))
(quote nia)
(list :rescheduled (quote yoga) (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0)))))))
;; an override on an occurrence nobody booked notifies no one
(let
((moved2 (ev-with-override ev (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 3 10 0) 60)))
(ev-rm-check!
"rescheduling an unbooked occurrence notifies no one"
(len (ev/reschedule-notifications b moved2))
0))
;; an event with no overrides yields no reschedule notifications
(ev-rm-check!
"event without overrides has no reschedule notifications"
(len (ev/reschedule-notifications b ev))
0))))))
(define
ev-reminders-tests-run!
(fn
()
(do
(set! ev-rm-pass 0)
(set! ev-rm-fail 0)
(set! ev-rm-failures (list))
(ev-rm-run-all!)
(ev-rm-rs-run-all!)
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))

252
lib/events/tests/ticket.sx Normal file
View File

@@ -0,0 +1,252 @@
;; lib/events/tests/ticket.sx — paid-ticket contract + settlement orchestration.
(define ev-tk-pass 0)
(define ev-tk-fail 0)
(define ev-tk-failures (list))
(define
ev-tk-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-tk-pass (+ ev-tk-pass 1))
(do
(set! ev-tk-fail (+ ev-tk-fail 1))
(append!
ev-tk-failures
(str name "\n expected: " expected "\n got: " got))))))
(define
ev-tk-run-all!
(fn
()
(do
(let
((req (ev/checkout-request "occ1" (quote nia) 1500 "GBP" "ref-1")))
(do
(ev-tk-check!
"checkout-request is tagged"
(ev/checkout-request? req)
true)
(ev-tk-check!
"payment-result is not a checkout-request"
(ev/checkout-request? (ev/payment-paid "o" (quote a) "r"))
false)
(ev-tk-check!
"request occ-key accessor"
(ev/req-occ-key req)
"occ1")
(ev-tk-check!
"request actor accessor"
(ev/req-actor req)
(quote nia))
(ev-tk-check!
"request amount accessor"
(ev/req-amount req)
1500)
(ev-tk-check!
"request currency accessor"
(ev/req-currency req)
"GBP")
(ev-tk-check! "request ref accessor" (ev/req-ref req) "ref-1")))
(let
((res (ev/payment-paid "occ1" (quote nia) "ref-1")))
(do
(ev-tk-check!
"payment-result is tagged"
(ev/payment-result? res)
true)
(ev-tk-check! "result status accessor" (ev/result-status res) :paid)
(ev-tk-check!
"failed constructor carries status"
(ev/result-status (ev/payment-failed "o" (quote a) "r"))
:failed)
(ev-tk-check!
"expired constructor carries status"
(ev/result-status (ev/payment-expired "o" (quote a) "r"))
:expired)))
(let
((b (persist/open)))
(do
(let
((r (ev/request-ticket! b "show" 1 (quote a) 2000 "GBP" "ref-a")))
(do
(ev-tk-check!
"request-ticket awaiting-payment"
(get r :status)
:awaiting-payment)
(ev-tk-check!
"request-ticket returns a checkout-request"
(ev/checkout-request? (get r :request))
true)
(ev-tk-check!
"checkout-request carries the amount"
(ev/req-amount (get r :request))
2000)))
(ev-tk-check!
"held seat reserves capacity"
(ev/seats-left b "show" 1)
0)
(ev-tk-check!
"second buyer is full while payment pends"
(get
(ev/request-ticket!
b
"show"
1
(quote c)
2000
"GBP"
"ref-c")
:status)
:full)
(ev-tk-check!
"held seat state pending"
(ev/seat-state b "show" (quote a))
:held)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"gig"
2
(quote a)
2000
"GBP"
"ref-a")
(let
((s (ev/settle-payment! b (ev/payment-paid "gig" (quote a) "ref-a"))))
(ev-tk-check! "settle paid confirms" (get s :status) :confirmed))
(ev-tk-check!
"confirmed seat state"
(ev/seat-state b "gig" (quote a))
:confirmed)
(ev-tk-check!
"redelivered paid is still confirmed (idempotent)"
(get
(ev/settle-payment!
b
(ev/payment-paid "gig" (quote a) "ref-a"))
:status)
:confirmed)
(ev-tk-check!
"still exactly one seat taken"
(ev-booking-count b "gig")
1)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"fail"
1
(quote a)
2000
"GBP"
"ref-a")
(ev-tk-check!
"seat held before failure"
(ev/seats-left b "fail" 1)
0)
(let
((s (ev/settle-payment! b (ev/payment-failed "fail" (quote a) "ref-a"))))
(ev-tk-check! "settle failed releases" (get s :status) :released))
(ev-tk-check!
"released seat frees capacity"
(ev/seats-left b "fail" 1)
1)
(ev-tk-check!
"redelivered failure is a noop"
(get
(ev/settle-payment!
b
(ev/payment-failed "fail" (quote a) "ref-a"))
:status)
:noop)
(ev-tk-check!
"freed seat available to next buyer"
(get
(ev/request-ticket!
b
"fail"
1
(quote c)
2000
"GBP"
"ref-c")
:status)
:awaiting-payment)
(ev/request-ticket!
b
"exp"
1
(quote a)
2000
"GBP"
"ref-a")
(ev-tk-check!
"settle expired releases"
(get
(ev/settle-payment!
b
(ev/payment-expired "exp" (quote a) "ref-a"))
:status)
:released)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"race"
1
(quote a)
2000
"GBP"
"ref-a")
(ev/settle-payment!
b
(ev/payment-expired "race" (quote a) "ref-a"))
(ev-tk-check!
"late paid for a vanished hold needs a refund"
(get
(ev/settle-payment!
b
(ev/payment-paid "race" (quote a) "ref-a"))
:status)
:paid-but-no-hold)
(ev-tk-check!
"no phantom seat created"
(ev-booking-count b "race")
0)))
(let
((b (persist/open)))
(do
(let
((start (ev/request-ticket! b "e2e" 3 (quote nia) 2500 "GBP" "ref-nia")))
(ev/settle-payment!
b
(ev/payment-paid
(ev/req-occ-key (get start :request))
(ev/req-actor (get start :request))
(ev/req-ref (get start :request)))))
(ev-tk-check!
"e2e roster holds the buyer"
(ev/roster b "e2e")
(list (quote nia)))
(ev-tk-check!
"e2e seat confirmed"
(ev/seat-state b "e2e" (quote nia))
:confirmed))))))
(define
ev-ticket-tests-run!
(fn
()
(do
(set! ev-tk-pass 0)
(set! ev-tk-fail 0)
(set! ev-tk-failures (list))
(ev-tk-run-all!)
{:failures ev-tk-failures :total (+ ev-tk-pass ev-tk-fail) :passed ev-tk-pass :failed ev-tk-fail})))

View File

@@ -0,0 +1,225 @@
;; lib/events/tests/timezone.sx — timezones + DST.
(define ev-tz-pass 0)
(define ev-tz-fail 0)
(define ev-tz-failures (list))
(define
ev-tz-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-tz-pass (+ ev-tz-pass 1))
(do
(set! ev-tz-fail (+ ev-tz-fail 1))
(append!
ev-tz-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Wall-clock (civil + minute-of-day) an occurrence's UTC start maps to in a tz.
(define
ev-tz-local-of
(fn
(tz utc-dt)
(let
((l (ev-tz-utc->local tz utc-dt)))
(list (ev-dt->civil l) (ev-dt-tod l)))))
(define
ev-tz-run-all!
(fn
()
(do
(let
((nyc (ev-tz-fixed "EST" -300)))
(do
(ev-tz-check!
"fixed zone: utc -> local subtracts 5h"
(ev-tz-utc->local
nyc
(ev-dt 2026 1 1 17 0))
(ev-dt 2026 1 1 12 0))
(ev-tz-check!
"fixed zone: local -> utc adds 5h back"
(ev-tz-local->utc
nyc
(ev-dt 2026 1 1 12 0))
(ev-dt 2026 1 1 17 0))
(ev-tz-check!
"UTC zone is identity"
(ev-tz-local->utc
ev-tz-utc
(ev-dt 2026 6 1 9 0))
(ev-dt 2026 6 1 9 0))))
(ev-tz-check!
"London winter offset is 0 (GMT)"
(ev-tz-offset
ev-tz-london
(ev-dt 2026 1 15 12 0))
0)
(ev-tz-check!
"London summer offset is 60 (BST)"
(ev-tz-offset
ev-tz-london
(ev-dt 2026 7 15 12 0))
60)
(ev-tz-check!
"Paris winter offset is 60 (CET)"
(ev-tz-offset
ev-tz-paris
(ev-dt 2026 1 15 12 0))
60)
(ev-tz-check!
"Paris summer offset is 120 (CEST)"
(ev-tz-offset
ev-tz-paris
(ev-dt 2026 7 15 12 0))
120)
;; ---- southern hemisphere (reversed seasons) ----
(ev-tz-check!
"Sydney January offset is 660 (AEDT, summer DST)"
(ev-tz-offset
ev-tz-sydney
(ev-dt 2026 1 15 12 0))
660)
(ev-tz-check!
"Sydney July offset is 600 (AEST, winter std)"
(ev-tz-offset
ev-tz-sydney
(ev-dt 2026 7 15 12 0))
600)
(ev-tz-check!
"Sydney DST starts first Sunday of October"
(ev-dt->civil
(+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-start)) 480))
(list 2026 10 4))
(ev-tz-check!
"Sydney DST ends first Sunday of April"
(ev-dt->civil
(+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-end)) 480))
(list 2026 4 5))
(ev-tz-check!
"09:00 Sydney in summer (AEDT) is previous-day 22:00 UTC"
(ev-tz-local->utc
ev-tz-sydney
(ev-dt 2026 1 15 9 0))
(ev-dt 2026 1 14 22 0))
(ev-tz-check!
"09:00 Sydney in winter (AEST) is previous-day 23:00 UTC"
(ev-tz-local->utc
ev-tz-sydney
(ev-dt 2026 7 15 9 0))
(ev-dt 2026 7 14 23 0))
(let
((au (ev-event-tz (quote au) (ev-dt 2026 4 3 9 0) 60 {:freq :daily :count 5} 8 ev-tz-sydney)))
(let
((occs (ev-expand au (ev-date 2026 3 25) (ev-date 2026 4 12))))
(do
(ev-tz-check!
"Sydney daily occurrences shift in UTC across the autumn DST end"
(map (fn (o) (ev-dt-tod (get o :start))) occs)
(list 1320 1320 1380 1380 1380))
(ev-tz-check!
"but every Sydney occurrence stays 09:00 local wall-clock"
(map
(fn
(o)
(first (rest (ev-tz-local-of ev-tz-sydney (get o :start)))))
occs)
(list 540 540 540 540 540)))))
(ev-tz-check!
"DST starts last Sunday of March"
(ev-dt->civil
(ev-tz-transition
2026
(ev-tz-rule 3 -1 6 60)))
(list 2026 3 29))
(ev-tz-check!
"DST ends last Sunday of October"
(ev-dt->civil
(ev-tz-transition
2026
(ev-tz-rule 10 -1 6 60)))
(list 2026 10 25))
(ev-tz-check!
"09:00 London in winter is 09:00 UTC"
(ev-tz-local->utc
ev-tz-london
(ev-dt 2026 1 15 9 0))
(ev-dt 2026 1 15 9 0))
(ev-tz-check!
"09:00 London in summer is 08:00 UTC"
(ev-tz-local->utc
ev-tz-london
(ev-dt 2026 7 15 9 0))
(ev-dt 2026 7 15 8 0))
(ev-tz-check!
"round trip utc -> local -> utc"
(ev-tz-local->utc
ev-tz-london
(ev-tz-utc->local
ev-tz-london
(ev-dt 2026 7 15 8 0)))
(ev-dt 2026 7 15 8 0))
(let
((ev (ev-event-tz (quote standup) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 5} 10 ev-tz-london)))
(let
((occs (ev-expand ev (ev-date 2026 3 1) (ev-date 2026 4 5))))
(do
(ev-tz-check!
"daily occurrences shift in UTC across the DST boundary"
(map (fn (o) (ev-dt-tod (get o :start))) occs)
(list 540 540 480 480 480))
(ev-tz-check!
"but every occurrence stays 09:00 local wall-clock"
(map
(fn
(o)
(first
(rest (ev-tz-local-of ev-tz-london (get o :start)))))
occs)
(list 540 540 540 540 540))
(ev-tz-check!
"occurrence dates are stable in local time"
(map
(fn
(o)
(ev-civ-d
(first (ev-tz-local-of ev-tz-london (get o :start)))))
occs)
(list 27 28 29 30 31)))))
(let
((wk (ev-event-tz (quote class) (ev-dt 2026 3 23 18 0) 90 {:freq :weekly :byday (list 0)} 5 ev-tz-london)))
(let
((occs (ev-expand wk (ev-date 2026 3 1) (ev-date 2026 4 20))))
(ev-tz-check!
"weekly Monday 18:00 London stays 18:00 local each week"
(map
(fn
(o)
(first (rest (ev-tz-local-of ev-tz-london (get o :start)))))
occs)
(list 1080 1080 1080 1080))))
(let
((plain (ev-event (quote p) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 3} 1)))
(ev-tz-check!
"plain event expands naively (no UTC shift)"
(map
(fn (o) (ev-dt-tod (get o :start)))
(ev-expand
plain
(ev-date 2026 3 1)
(ev-date 2026 4 5)))
(list 540 540 540))))))
(define
ev-timezone-tests-run!
(fn
()
(do
(set! ev-tz-pass 0)
(set! ev-tz-fail 0)
(set! ev-tz-failures (list))
(ev-tz-run-all!)
{:failures ev-tz-failures :total (+ ev-tz-pass ev-tz-fail) :passed ev-tz-pass :failed ev-tz-fail})))

101
lib/events/ticket.sx Normal file
View File

@@ -0,0 +1,101 @@
;; lib/events/ticket.sx — paid-ticket contract between events and commerce.
;;
;; A paid booking spans two subsystems. events does NOT import commerce; instead
;; this module defines the CONTRACT — the two messages on the wire — and the
;; events-side orchestration over provisional holds (booking.sx). commerce
;; imports these shapes; the dependency only points one way.
;;
;; checkout-request events -> commerce "take payment for this seat"
;; {:kind :events.checkout :occ-key :actor :amount :currency :ref}
;;
;; payment-result commerce -> events "here's how payment went"
;; {:kind :events.payment :occ-key :actor :ref :status}
;; :status ∈ :paid | :failed | :expired
;;
;; Flow: ev/request-ticket! places a capacity-safe HOLD (reserving the seat so
;; it can't be oversold while payment pends) and returns a checkout-request to
;; hand to commerce. When commerce reports back, ev/settle-payment! confirms the
;; hold on :paid or releases it otherwise. Settlement is idempotent — an
;; at-least-once redelivery of the same result is safe. `ref` is the opaque
;; correlation/idempotency id; occ-key + actor locate the hold, so settlement
;; needs no side table.
;; ---- contract: checkout request (events -> commerce) ----
(define
ev/checkout-request
(fn (occ-key actor amount currency ref) {:actor actor :amount amount :kind :events.checkout :ref ref :currency currency :occ-key occ-key}))
(define
ev/checkout-request?
(fn (m) (and (dict? m) (= (get m :kind) :events.checkout))))
(define ev/req-occ-key (fn (r) (get r :occ-key)))
(define ev/req-actor (fn (r) (get r :actor)))
(define ev/req-amount (fn (r) (get r :amount)))
(define ev/req-currency (fn (r) (get r :currency)))
(define ev/req-ref (fn (r) (get r :ref)))
;; ---- contract: payment result (commerce -> events) ----
(define ev/payment-result (fn (occ-key actor ref status) {:actor actor :kind :events.payment :status status :ref ref :occ-key occ-key}))
(define
ev/payment-result?
(fn (m) (and (dict? m) (= (get m :kind) :events.payment))))
(define ev/result-occ-key (fn (r) (get r :occ-key)))
(define ev/result-actor (fn (r) (get r :actor)))
(define ev/result-ref (fn (r) (get r :ref)))
(define ev/result-status (fn (r) (get r :status)))
(define
ev/payment-paid
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :paid)))
(define
ev/payment-failed
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :failed)))
(define
ev/payment-expired
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :expired)))
;; ---- orchestration ----
;; Begin a paid booking: place a capacity-safe hold and, if reserved, return a
;; checkout-request for commerce. :full when no seat; :already when the actor
;; already holds/booked this occurrence (no duplicate request).
(define
ev/request-ticket!
(fn
(b occ-key capacity actor amount currency ref)
(let
((h (ev/hold! b occ-key capacity actor)))
(cond
((= (get h :status) :held) {:seat (get h :seat) :request (ev/checkout-request occ-key actor amount currency ref) :status :awaiting-payment})
((= (get h :status) :already) {:seat (get h :seat) :status :already})
(else {:capacity capacity :status :full})))))
;; Settle a payment result from commerce. :paid confirms the hold; :failed /
;; :expired release it. Idempotent: a redelivered :paid stays :confirmed, a
;; redelivered release is a :noop. If a :paid arrives for a hold that is already
;; gone (released/expired first), returns :paid-but-no-hold so the caller can
;; trigger a refund.
(define
ev/settle-payment!
(fn
(b result)
(let
((occ-key (ev/result-occ-key result))
(actor (ev/result-actor result))
(ref (ev/result-ref result)))
(if
(= (ev/result-status result) :paid)
(let
((c (ev/confirm! b occ-key actor)))
(cond
((= (get c :status) :confirmed) {:actor actor :status :confirmed :ref ref})
((= (get c :status) :already-confirmed) {:actor actor :status :confirmed :ref ref})
(else {:actor actor :status :paid-but-no-hold :ref ref})))
(let
((r (ev/release! b occ-key actor)))
(if (= (get r :status) :released) {:actor actor :status :released :ref ref} {:actor actor :status :noop :ref ref}))))))

157
lib/events/timezone.sx Normal file
View File

@@ -0,0 +1,157 @@
;; lib/events/timezone.sx — timezones + DST for the calendar.
;;
;; Datetimes in calendar.sx are naive epoch-minutes (wall clock). A timezone
;; maps between wall-clock LOCAL time and absolute UTC. An event is authored in
;; local time + a tz; recurrence is expanded in local time (so a "09:00 weekly"
;; meeting stays 09:00 across a DST change), then each occurrence is converted
;; to UTC for storage/comparison.
;;
;; Offset convention: offset = local - utc (minutes). London summer (BST) = +60.
;; UTC = local - offset; local = utc + offset.
;;
;; Two kinds of zone, no IANA database:
;; :fixed — a constant offset.
;; :dst — std/dst offsets + two transition rules. Transitions are given in
;; UTC (EU zones all switch at 01:00 UTC), so the offset at any UTC
;; instant is a direct range check; no recursion. Both hemispheres
;; are supported: northern zones have dst-start < dst-end (DST is the
;; interval [start, end)); southern zones have dst-start > dst-end
;; (DST wraps the year boundary), detected by comparing the two
;; transitions — see ev-tz-offset.
;;
;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days,
;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday).
;; A DST transition rule: the ord-th weekday `wd` (0=Mon..6=Sun) of `month`, at
;; `time` minutes-of-day UTC. EU: last Sunday (ord -1, wd 6) at 01:00 UTC.
(define ev-tz-rule (fn (month ord wd time) {:ord ord :wd wd :month month :time time}))
(define ev-tz-fixed (fn (name offset) {:name name :offset offset :kind :fixed}))
(define ev-tz-dst (fn (name std dst start-rule end-rule) {:name name :kind :dst :dst-end end-rule :dst-start start-rule :std-offset std :dst-offset dst}))
;; Standard (winter) offset — the initial guess when inverting local -> utc.
(define
ev-tz-std-offset
(fn
(tz)
(if (= (get tz :kind) :fixed) (get tz :offset) (get tz :std-offset))))
;; The UTC instant (epoch-minutes) of a transition rule in a given year.
(define
ev-tz-transition
(fn
(year rule)
(let
((day (ev-resolve-nth-weekday year (get rule :month) (get rule :ord) (get rule :wd))))
(+
(* (ev-days-from-civil year (get rule :month) day) 1440)
(get rule :time)))))
;; The offset (minutes) in effect at a UTC instant.
(define
ev-tz-offset
(fn
(tz utc-dt)
(cond
((= (get tz :kind) :fixed) (get tz :offset))
((= (get tz :kind) :dst)
(let
((year (ev-civ-y (ev-civil-from-days (ev-floor-div utc-dt 1440)))))
(let
((start (ev-tz-transition year (get tz :dst-start)))
(end (ev-tz-transition year (get tz :dst-end))))
;; Northern hemisphere: dst-start < dst-end, DST is the closed-open
;; interval [start, end). Southern hemisphere: dst-start > dst-end
;; (DST begins in spring ~Oct and ends ~Apr), so within a calendar
;; year DST wraps the boundary — active OUTSIDE [end, start).
(if
(< start end)
(if
(and (>= utc-dt start) (< utc-dt end))
(get tz :dst-offset)
(get tz :std-offset))
(if
(or (>= utc-dt start) (< utc-dt end))
(get tz :dst-offset)
(get tz :std-offset))))))
(else 0))))
;; UTC instant -> local wall-clock.
(define
ev-tz-utc->local
(fn (tz utc-dt) (+ utc-dt (ev-tz-offset tz utc-dt))))
;; Local wall-clock -> UTC instant. The offset depends on the instant, so we
;; guess with the standard offset and refine once (correct except within the
;; one-hour DST gap/overlap, where it resolves to the pre-transition offset).
(define
ev-tz-local->utc
(fn
(tz local-dt)
(let
((utc1 (- local-dt (ev-tz-offset tz (- local-dt (ev-tz-std-offset tz))))))
(- local-dt (ev-tz-offset tz utc1)))))
;; ---- predefined zones ----
(define ev-tz-utc (ev-tz-fixed "UTC" 0))
(define
ev-tz-london
(ev-tz-dst
"Europe/London"
0
60
(ev-tz-rule 3 -1 6 60)
(ev-tz-rule 10 -1 6 60)))
(define
ev-tz-paris
(ev-tz-dst
"Europe/Paris"
60
120
(ev-tz-rule 3 -1 6 60)
(ev-tz-rule 10 -1 6 60)))
;; Southern hemisphere: AEST +600 (std, winter), AEDT +660 (dst, summer). DST
;; begins 02:00 AEST first Sunday October and ends 03:00 AEDT first Sunday April
;; — both 16:00 UTC the preceding Saturday, i.e. -480 minutes from the Sunday in
;; the rule (the model adds rule :time to the resolved weekday's UTC midnight).
;; dst-start (Oct) > dst-end (Apr), so ev-tz-offset takes the wrap-the-year path.
(define
ev-tz-sydney
(ev-tz-dst
"Australia/Sydney"
600
660
(ev-tz-rule 10 1 6 -480)
(ev-tz-rule 4 1 6 -480)))
;; ---- tz-aware event expansion ----
;; An event authored in local time + a tz. dtstart-local / rrule / exceptions
;; are all wall-clock in `tz`; expansion converts each occurrence to UTC.
(define
ev-event-tz
(fn (id dtstart-local duration rrule capacity tz) {:id id :duration duration :dtstart dtstart-local :rrule rrule :capacity capacity :tz tz}))
;; Expand a tz-aware event over a UTC window. Local recurrence is expanded over
;; a window widened by a day each side (to catch occurrences whose UTC lands in
;; range), converted to UTC, then filtered to [win-start, win-end].
(define
ev-expand-tz
(fn
(event tz win-start win-end)
(let
((local-ws (- (ev-tz-utc->local tz win-start) 1440))
(local-we (+ (ev-tz-utc->local tz win-end) 1440)))
(let
((local-occs (ev-expand-naive event local-ws local-we)))
(let
((utc-occs (map (fn (o) (let ((u (ev-tz-local->utc tz (get o :start))) (dur (- (get o :end) (get o :start)))) {:id (get o :id) :start u :end (+ u dur)})) local-occs)))
(ev-sort-occs
(filter
(fn
(o)
(and
(>= (get o :start) win-start)
(<= (get o :start) win-end)))
utc-occs)))))))

38
lib/feed/acl.sx Normal file
View File

@@ -0,0 +1,38 @@
; feed/acl — per-viewer visibility filtering. The same candidate stream yields
; different timelines for different viewers, so ACL is applied per request and
; pre-ACL timelines are never cached.
;
; permit? is injected: (permit? viewer activity) -> bool. Wire a real acl-sx
; predicate here; feed/permit-acl? is a self-contained default that reads an
; optional :visible-to allowlist on the activity.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/-elem?), lib/feed/rank.sx (feed/top).
; default permit: actor always sees own activity; absent/nil :visible-to is
; public; otherwise viewer must be in the allowlist.
(define
feed/permit-acl?
(fn
(viewer a)
(or
(equal? viewer (get a :actor))
(let
((allowed (get a :visible-to nil)))
(if (= allowed nil) true (feed/-elem? viewer allowed))))))
(define feed/permit-public? (fn (viewer a) true))
; filter a stream to what viewer may read
(define
feed/visible
(fn
(stream viewer permit?)
(feed/filter stream (fn (a) (permit? viewer a)))))
; the capstone: candidate stream -> ACL for viewer -> rank -> top-N
(define
feed/timeline
(fn
(stream viewer permit? score-fn n)
(feed/top (feed/visible stream viewer permit?) score-fn n)))

62
lib/feed/aggregate.sx Normal file
View File

@@ -0,0 +1,62 @@
; feed/aggregate — group-by / counting via key-reduce. Keys must be strings
; (dict keys), so composite keys (actor, day) are joined into one string.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx.
; group activities into a dict: key-string -> (list of activities), order-preserving
(define
feed/group-by
(fn
(stream key-fn)
(reduce
(fn
(g a)
(let
((k (key-fn a)))
(assoc g k (append (get g k (list)) (list a)))))
{}
(feed/items stream))))
; key-string -> count
(define
feed/group-count
(fn
(stream key-fn)
(reduce
(fn
(g a)
(let
((k (key-fn a)))
(assoc g k (+ (get g k 0) 1))))
{}
(feed/items stream))))
; --- composite keys ---------------------------------------------------------
(define feed/day (fn (at window) (floor (/ at window))))
; (actor, day-bucket) -> "actor#day"
(define
feed/actor-day-key
(fn
(window)
(fn
(a)
(string-append
(get a :actor)
"#"
(number->string (feed/day (get a :at) window))))))
(define
feed/by-actor-day
(fn (stream window) (feed/group-count stream (feed/actor-day-key window))))
; per-actor activity counts
(define
feed/actor-counts
(fn (stream) (feed/group-count stream feed/actor)))
; per-object activity counts (engagement)
(define
feed/object-counts
(fn (stream) (feed/group-count stream feed/object)))

24
lib/feed/api.sx Normal file
View File

@@ -0,0 +1,24 @@
; feed/api — ergonomic API over the stream layer for non-APL callers.
; A single mutable activity log; post appends, all returns it as a stream.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx (loaded by harness).
(define feed/-log (list))
; post — normalize then append. Returns the stored activity.
(define
feed/post
(fn
(raw)
(let
((a (feed/normalize raw)))
(begin (set! feed/-log (append feed/-log (list a))) a))))
; all — the whole log as a stream (insertion order)
(define feed/all (fn () (feed/stream feed/-log)))
; reset! — clear the log (test hygiene)
(define feed/reset! (fn () (begin (set! feed/-log (list)) nil)))
; size — number of posted activities
(define feed/size (fn () (len feed/-log)))

125
lib/feed/conformance.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# lib/feed/conformance.sh — run feed test suites, emit scoreboard.json + scoreboard.md.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
if [ ! -x "$SX_SERVER" ]; then
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
fi
if [ ! -x "$SX_SERVER" ]; then
echo "ERROR: sx_server.exe not found." >&2
exit 1
fi
SUITES=(basic fanout rank integration content notify home dedupe trending mute page thread)
OUT_JSON="lib/feed/scoreboard.json"
OUT_MD="lib/feed/scoreboard.md"
run_suite() {
local suite=$1
local file="lib/feed/tests/${suite}.sx"
local TMP
TMP=$(mktemp)
cat > "$TMP" << EPOCHS
(epoch 1)
(load "spec/stdlib.sx")
(load "lib/r7rs.sx")
(load "lib/apl/runtime.sx")
(load "lib/feed/normalize.sx")
(load "lib/feed/stream.sx")
(load "lib/feed/api.sx")
(load "lib/feed/fanout.sx")
(load "lib/feed/dedupe.sx")
(load "lib/feed/aggregate.sx")
(load "lib/feed/rank.sx")
(load "lib/feed/acl.sx")
(load "lib/feed/fed.sx")
(load "lib/feed/content.sx")
(load "lib/feed/notify.sx")
(load "lib/feed/home.sx")
(load "lib/feed/trending.sx")
(load "lib/feed/mute.sx")
(load "lib/feed/page.sx")
(load "lib/feed/thread.sx")
(epoch 2)
(eval "(define feed-test-pass 0)")
(eval "(define feed-test-fail 0)")
(eval "(define feed-test (fn (name got expected) (if (= got expected) (set! feed-test-pass (+ feed-test-pass 1)) (set! feed-test-fail (+ feed-test-fail 1)))))")
(epoch 3)
(load "${file}")
(epoch 4)
(eval "(list feed-test-pass feed-test-fail)")
EPOCHS
local OUTPUT
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
rm -f "$TMP"
local LINE
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
if [ -z "$LINE" ]; then
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
| sed -E 's/^\(ok 4 //; s/\)$//')
fi
local P F
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
P=${P:-0}
F=${F:-0}
echo "${P} ${F}"
}
declare -A SUITE_PASS
declare -A SUITE_FAIL
TOTAL_PASS=0
TOTAL_FAIL=0
echo "Running feed conformance suite..." >&2
for s in "${SUITES[@]}"; do
read -r p f < <(run_suite "$s")
SUITE_PASS[$s]=$p
SUITE_FAIL[$s]=$f
TOTAL_PASS=$((TOTAL_PASS + p))
TOTAL_FAIL=$((TOTAL_FAIL + f))
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
done
# scoreboard.json
{
printf '{\n'
printf ' "suites": {\n'
first=1
for s in "${SUITES[@]}"; do
if [ $first -eq 0 ]; then printf ',\n'; fi
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
first=0
done
printf '\n },\n'
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
printf '}\n'
} > "$OUT_JSON"
# scoreboard.md
{
printf '# feed Conformance Scoreboard\n\n'
printf '_Generated by `lib/feed/conformance.sh`_\n\n'
printf '| Suite | Pass | Fail | Total |\n'
printf '|-------|-----:|-----:|------:|\n'
for s in "${SUITES[@]}"; do
p=${SUITE_PASS[$s]}
f=${SUITE_FAIL[$s]}
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
done
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
} > "$OUT_MD"
echo "Wrote $OUT_JSON and $OUT_MD" >&2
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
[ "$TOTAL_FAIL" -eq 0 ]

68
lib/feed/content.sx Normal file
View File

@@ -0,0 +1,68 @@
; feed/content — TF-IDF relevance over activity :tags. Rare tags carry more
; signal, so an activity matching an uncommon tag ranks above one matching a
; common tag. Composes with rank.sx: feed/tfidf-score is just another scorer.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/-distinct), lib/feed/rank.sx (feed/rank).
; document frequency: tag -> number of activities whose :tags contain it
; (a tag repeated within one activity counts once toward df)
(define
feed/tag-df
(fn
(stream)
(reduce
(fn
(df a)
(reduce
(fn (d t) (assoc d t (+ (get d t 0) 1)))
df
(feed/-distinct (get a :tags))))
{}
(feed/items stream))))
; inverse document frequency: tag -> log(N / df)
(define
feed/tag-idf
(fn
(stream)
(let
((n (feed/count stream)) (df (feed/tag-df stream)))
(reduce
(fn (idf t) (assoc idf t (log (/ n (get df t)))))
{}
(keys df)))))
; term frequency within one activity: tag -> occurrence count
(define
feed/-tf
(fn
(a)
(reduce
(fn (tf t) (assoc tf t (+ (get tf t 0) 1)))
{}
(get a :tags))))
; relevance of an activity to a query (list of tags) given precomputed idf:
; sum over query tags of tf(tag in activity) * idf(tag in corpus)
(define
feed/tfidf-score
(fn
(idf query)
(fn
(a)
(let
((tf (feed/-tf a)))
(reduce
(fn
(acc t)
(+ acc (* (get tf t 0) (get idf t 0))))
0
query)))))
; rank a stream by relevance to query tags (idf computed over the stream itself)
(define
feed/by-relevance
(fn
(stream query)
(feed/rank stream (feed/tfidf-score (feed/tag-idf stream) query))))

76
lib/feed/dedupe.sx Normal file
View File

@@ -0,0 +1,76 @@
; feed/dedupe — collapse duplicate items, keeping first occurrence per key.
; Each verb may want its own key (see briefing): "alice posted X" keys on
; (actor verb object) — distinct per actor; "alice liked X / bob liked X"
; collapse on (verb object) so the cross-actor likes fold into one.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/-elem? lives in fanout.sx).
; generic: dedupe a stream by key-fn, first occurrence wins (stable)
(define
feed/-dedup-by
(fn
(items key-fn)
(get
(reduce
(fn
(st x)
(let
((k (key-fn x)))
(if (feed/-elem? k (get st :seen)) st {:seen (append (get st :seen) (list k)) :out (append (get st :out) (list x))})))
{:seen (list) :out (list)}
items)
:out)))
(define
feed/dedupe
(fn
(stream key-fn)
(feed/stream (feed/-dedup-by (feed/items stream) key-fn))))
; --- keys -------------------------------------------------------------------
(define
feed/activity-key
(fn (a) (list (get a :actor) (get a :verb) (get a :object))))
; collapse cross-actor duplicates of the same verb+object (e.g. likes)
(define feed/collapse-key (fn (a) (list (get a :verb) (get a :object))))
; per-receiver inbox key — one inbox event per (receiver, actor, verb, object)
(define
feed/event-key
(fn
(ev)
(let
((a (get ev :activity)))
(list (get ev :to) (get a :actor) (get a :verb) (get a :object)))))
; verbs whose duplicates collapse across actors (reactions, not authorship).
; rebindable: callers can (set! feed/collapse-verbs ...) to tune the policy.
(define
feed/collapse-verbs
(list "like" "favourite" "follow" "boost" "repost"))
; per-verb key: collapse-verbs fold on (verb object); the rest key on
; (actor verb object).
(define
feed/smart-key
(fn
(a)
(if
(feed/-elem? (get a :verb) feed/collapse-verbs)
(feed/collapse-key a)
(feed/activity-key a))))
; --- ready-made dedupers ----------------------------------------------------
(define feed/dedupe-activities (fn (s) (feed/dedupe s feed/activity-key)))
(define feed/dedupe-collapse (fn (s) (feed/dedupe s feed/collapse-key)))
; verb-aware: reactions collapse cross-actor, posts stay distinct per actor
(define feed/dedupe-smart (fn (s) (feed/dedupe s feed/smart-key)))
; dedupe an inbox: at most one event per receiver per (actor verb object)
(define feed/dedupe-inbox (fn (inbox) (feed/dedupe inbox feed/event-key)))

114
lib/feed/fanout.sx Normal file
View File

@@ -0,0 +1,114 @@
; feed/fanout — THE SHOWCASE. Fan activities out to followers via the APL outer
; product (∘.×). activities ∘.× audience → an (activity × follower) matrix of
; inbox events; flatten to a vector; guard-keep only real follow edges.
;
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
;
; NOTE: apl-outer's combiner result is run through (if (scalar? r) (disclose r) r).
; A bare dict counts as a scalar (shape ()) and disclose nils it — so the combiner
; must (enclose ...) its event dict; apl-outer then discloses it back intact.
; --- graph: {followee -> (list of followers)} -------------------------------
(define feed/followers (fn (graph user) (get graph user (list))))
; build a graph from (follower followee) edges: "follower follows followee"
(define
feed/follow-graph
(fn
(edges)
(reduce
(fn
(g e)
(let
((follower (first e)) (followee (nth e 1)))
(assoc
g
followee
(append (feed/followers g followee) (list follower)))))
{}
edges)))
; --- helpers ----------------------------------------------------------------
; unwrap an apl-scalar (has :ravel) back to its value; pass activities through
(define
feed/-val
(fn
(x)
(if (and (= (type-of x) "dict") (has-key? x :ravel)) (disclose x) x)))
(define feed/-elem? (fn (x lst) (some (fn (y) (equal? x y)) lst)))
(define
feed/-distinct
(fn
(lst)
(if
(= (len lst) 0)
(list)
(get (apl-unique (make-array (list (len lst)) lst)) :ravel))))
; rank-2 matrix -> rank-1 stream of its ravel
(define feed/-flatten (fn (arr) (feed/stream (get arr :ravel))))
; distinct receivers across the whole graph, sorted for determinism
; (dict key order is unspecified, so sort to pin audience/recipient ordering)
(define
feed/audience
(fn
(graph)
(sort
(feed/-distinct
(reduce
(fn (acc k) (append acc (feed/followers graph k)))
(list)
(keys graph))))))
; --- the outer product ------------------------------------------------------
; one (activity, follower) inbox event, enclosed so apl-outer keeps the dict
(define feed/-mk-event (fn (a f) (enclose {:activity (feed/-val a) :to (feed/-val f)})))
; keep events where :to actually follows the activity's actor
(define
feed/-edge?
(fn
(graph)
(fn
(ev)
(feed/-elem?
(get ev :to)
(feed/followers graph (get (get ev :activity) :actor))))))
; fanout — activities ∘.× audience, flatten, guard-keep real edges
(define
feed/fanout
(fn
(stream graph)
(let
((matrix (apl-outer feed/-mk-event stream (feed/stream (feed/audience graph)))))
(feed/filter (feed/-flatten matrix) (feed/-edge? graph)))))
; --- inbox queries ----------------------------------------------------------
(define
feed/inbox-for
(fn
(inbox user)
(feed/filter inbox (fn (ev) (equal? (get ev :to) user)))))
(define
feed/recipients
(fn
(inbox)
(feed/-distinct (map (fn (ev) (get ev :to)) (feed/items inbox)))))
; the activities (unwrapped) destined for a user
(define
feed/inbox-activities
(fn
(inbox user)
(map
(fn (ev) (get ev :activity))
(feed/items (feed/inbox-for inbox user)))))

60
lib/feed/fed.sx Normal file
View File

@@ -0,0 +1,60 @@
; feed/fed — federation. Outbound: a local post fans out, then splits into local
; vs remote inboxes; remote events are handed to an injected send-fn. Inbound:
; peer activities merge into the local stream, deduped. Backfill: pull peer
; history via an injected fetch-fn and merge.
;
; remote? / send-fn / fetch-fn are injected so real fed-sx transport wires in here
; without feed depending on it.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx,
; lib/feed/dedupe.sx.
; --- merge / ingest ---------------------------------------------------------
(define
feed/merge
(fn (s1 s2) (feed/stream (append (feed/items s1) (feed/items s2)))))
; merge a peer stream into local, dropping (actor verb object) duplicates
(define
feed/ingest
(fn (local peer) (feed/dedupe-activities (feed/merge local peer))))
; --- inbound ----------------------------------------------------------------
; peer pushes raw activities to the local inbox; normalize + ingest
(define
feed/inbound
(fn
(local raw-activities)
(feed/ingest local (feed/stream (map feed/normalize raw-activities)))))
; backfill on subscribe: pull peer history via fetch-fn, normalize, ingest
(define
feed/backfill
(fn (local fetch-fn peer-id) (feed/inbound local (fetch-fn peer-id))))
; --- outbound ---------------------------------------------------------------
; split an inbox into local vs remote deliveries by viewer-id predicate
(define feed/partition-inbox (fn (inbox remote?) {:local (feed/filter inbox (fn (ev) (not (remote? (get ev :to))))) :remote (feed/filter inbox (fn (ev) (remote? (get ev :to))))}))
; fan a stream out over the graph, then partition by locality
(define
feed/federate
(fn
(stream graph remote?)
(feed/partition-inbox (feed/fanout stream graph) remote?)))
; deliver: hand each remote event to send-fn, return the local inbox to enqueue
(define
feed/deliver
(fn
(stream graph remote? send-fn)
(let
((parts (feed/federate stream graph remote?)))
(begin
(for-each
(fn (ev) (send-fn (get ev :to) (get ev :activity)))
(feed/items (get parts :remote)))
(get parts :local)))))

23
lib/feed/home.sx Normal file
View File

@@ -0,0 +1,23 @@
; feed/home — the capstone. A user's home timeline is the whole pipeline as one
; line: fan all activities out over the follow graph, take the events landing in
; the viewer's inbox, dedupe cross-posts, apply the viewer's ACL, rank, take N.
;
; Requires: fanout.sx, dedupe.sx, acl.sx (feed/timeline), rank.sx, stream.sx.
; the activities in a user's inbox, as a stream
(define
feed/inbox-stream
(fn (inbox user) (feed/stream (feed/inbox-activities inbox user))))
; fanout ∘ inbox ∘ dedupe ∘ ACL ∘ rank ∘ take
(define
feed/home
(fn
(stream graph viewer permit? score-fn n)
(feed/timeline
(feed/dedupe-activities
(feed/inbox-stream (feed/fanout stream graph) viewer))
viewer
permit?
score-fn
n)))

44
lib/feed/mute.sx Normal file
View File

@@ -0,0 +1,44 @@
; feed/mute — viewer-controlled filtering. ACL (acl.sx) is author-controlled
; visibility; mute is the reader's own preference: hide muted actors or tags.
; Like ACL it is per-viewer and applied per request, never cached.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/-elem?).
; drop activities authored by a muted actor
(define
feed/mute-actors
(fn
(stream actors)
(feed/filter
stream
(fn (a) (not (feed/-elem? (get a :actor) actors))))))
; drop activities carrying any muted tag
(define
feed/mute-tags
(fn
(stream tags)
(feed/filter
stream
(fn (a) (not (some (fn (t) (feed/-elem? t tags)) (get a :tags)))))))
; drop activities about a muted object (thread mute)
(define
feed/mute-objects
(fn
(stream objects)
(feed/filter
stream
(fn (a) (not (feed/-elem? (get a :object) objects))))))
; apply a viewer preference bag: {:mute-actors (...) :mute-tags (...) :mute-objects (...)}
(define
feed/apply-prefs
(fn
(stream prefs)
(feed/mute-objects
(feed/mute-tags
(feed/mute-actors stream (get prefs :mute-actors (list)))
(get prefs :mute-tags (list)))
(get prefs :mute-objects (list)))))

31
lib/feed/normalize.sx Normal file
View File

@@ -0,0 +1,31 @@
; feed/normalize — coerce arbitrary input into the canonical activity record.
; An activity is a small dict {:actor :verb :object :at :tags}; a stream is an
; APL vector of such dicts (see stream.sx). Extra keys on the raw input survive
; (e.g. :visible-to for ACL, peer metadata for federation) — :tags is the
; flexible bag but the record is not closed.
(define feed/activity-keys (list :actor :verb :object :at :tags))
(define
feed/normalize
(fn
(raw)
(let
((d (if (= (type-of raw) "dict") raw {})))
(merge d {:actor (get d :actor "") :object (get d :object nil) :at (get d :at 0) :tags (let ((t (get d :tags (list)))) (if (list? t) t (list t))) :verb (get d :verb "post")}))))
(define
feed/activity
(fn (actor verb object at tags) (feed/normalize {:actor actor :object object :at at :tags tags :verb verb})))
(define feed/actor (fn (a) (get a :actor)))
(define feed/verb (fn (a) (get a :verb)))
(define feed/object (fn (a) (get a :object)))
(define feed/at (fn (a) (get a :at)))
(define feed/tags (fn (a) (get a :tags)))
(define
feed/activity?
(fn
(a)
(and (= (type-of a) "dict") (has-key? a :actor) (has-key? a :verb))))

45
lib/feed/notify.sx Normal file
View File

@@ -0,0 +1,45 @@
; feed/notify — a notification feed is a thin layer over a recipient's inbox:
; the events directed at a user, optionally verb-filtered, and a digest that
; collapses "alice, bob and 1 other liked X" by (verb, object).
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/inbox-for, feed/-elem?).
; all inbox events for a user (their raw notifications)
(define feed/notifications (fn (inbox user) (feed/inbox-for inbox user)))
; restrict to notification-worthy verbs (e.g. (list "like" "reply" "follow"))
(define
feed/notify-verbs
(fn
(inbox user verbs)
(feed/filter
(feed/inbox-for inbox user)
(fn (ev) (feed/-elem? (get (get ev :activity) :verb) verbs)))))
; group key "verb|object" — deterministic, sortable
(define
feed/-notify-key
(fn
(ev)
(let
((a (get ev :activity)))
(string-append (get a :verb) "|" (get a :object)))))
; digest: one entry per (verb, object) with the distinct actors and a count,
; ordered by key for determinism.
(define
feed/notify-digest
(fn
(inbox user)
(let
((events (feed/items (feed/inbox-for inbox user))))
(let
((groups (reduce (fn (g ev) (let ((a (get ev :activity)) (k (feed/-notify-key ev))) (let ((cur (get g k {:object (get a :object) :actors (list) :verb (get a :verb)}))) (assoc g k (assoc cur :actors (append (get cur :actors) (list (get a :actor)))))))) {} events)))
(map
(fn
(k)
(let
((grp (get groups k)))
(assoc grp :count (len (get grp :actors)))))
(sort (keys groups)))))))

50
lib/feed/page.sx Normal file
View File

@@ -0,0 +1,50 @@
; feed/page — pagination. Offset/limit for indexed access, and cursor-based
; (by :at) for recency feeds, which is stable under inserts: a cursor is the
; :at of the last item seen, and the next page is the newest items older than it.
;
; Requires: lib/feed/stream.sx (feed/recent, feed/take, feed/filter).
; --- offset / limit ---------------------------------------------------------
(define
feed/page
(fn
(stream offset limit)
(feed/stream (take (drop (feed/items stream) offset) limit))))
(define
feed/page-count
(fn (stream limit) (ceil (/ (feed/count stream) limit))))
; --- cursor (recency feeds) -------------------------------------------------
; activities strictly older than cursor (scroll down / load older)
(define
feed/before
(fn
(stream cursor)
(feed/filter stream (fn (a) (< (get a :at) cursor)))))
; activities strictly newer than cursor (load newer / "N new posts")
(define
feed/after
(fn
(stream cursor)
(feed/filter stream (fn (a) (> (get a :at) cursor)))))
; one page: the `limit` newest activities older than cursor, newest first
(define
feed/page-before
(fn
(stream cursor limit)
(feed/take (feed/recent (feed/before stream cursor)) limit)))
; cursor to fetch the next (older) page: :at of the last item of a page,
; or nil when the page is empty (end of feed)
(define
feed/next-cursor
(fn
(page)
(let
((items (feed/items page)))
(if (= (len items) 0) nil (get (last items) :at)))))

92
lib/feed/rank.sx Normal file
View File

@@ -0,0 +1,92 @@
; feed/rank — scoring + ranking. Scorers are (activity -> number). Ranking is a
; stable two-pass grade-down: first by :at descending (the tiebreak), then by
; score descending — so ties resolve by recency, then by input order. Fully
; deterministic on ties.
;
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
; --- scorers ----------------------------------------------------------------
; recency: half-life decay. score = 0.5 ^ (age / half-life). at==now -> 1.0.
(define
feed/recency
(fn
(now half-life)
(fn (a) (expt 0.5 (/ (- now (get a :at)) half-life)))))
; velocity: how many of this actor's activities fall in (at-window, at] —
; a burst of recent activity scores higher.
(define
feed/velocity
(fn
(stream window)
(fn
(a)
(len
(filter
(fn
(b)
(and
(equal? (get b :actor) (get a :actor))
(<= (get b :at) (get a :at))
(> (get b :at) (- (get a :at) window))))
(feed/items stream))))))
; engagement: how many activities in the stream touch this activity's :object
(define
feed/engagement
(fn
(stream)
(fn
(a)
(len
(filter
(fn (b) (equal? (get b :object) (get a :object)))
(feed/items stream))))))
; composite: weighted sum. parts = (list (list weight scorer) ...)
(define
feed/composite
(fn
(parts)
(fn
(a)
(reduce
(fn (acc p) (+ acc (* (first p) ((nth p 1) a))))
0
parts))))
; --- ranking ----------------------------------------------------------------
; stable reorder of items by key-fn, descending (grade-down is stable)
(define
feed/-desc-by
(fn
(items key-fn)
(let
((keys (make-array (list (len items)) (map key-fn items))))
(let
((order (get (apl-grade-down keys) :ravel)))
(map (fn (i) (nth items (- i 1))) order)))))
; rank by score descending; ties -> :at descending -> input order
(define
feed/rank
(fn
(stream score-fn)
(let
((by-at (feed/-desc-by (feed/items stream) feed/at)))
(feed/stream (feed/-desc-by by-at score-fn)))))
; attach a :score to each activity (for inspection / debugging)
(define
feed/with-scores
(fn
(stream score-fn)
(feed/stream
(map (fn (a) (assoc a :score (score-fn a))) (feed/items stream)))))
; top-N ranked timeline
(define
feed/top
(fn (stream score-fn n) (feed/take (feed/rank stream score-fn) n)))

19
lib/feed/scoreboard.json Normal file
View File

@@ -0,0 +1,19 @@
{
"suites": {
"basic": {"pass": 30, "fail": 0},
"fanout": {"pass": 29, "fail": 0},
"rank": {"pass": 24, "fail": 0},
"integration": {"pass": 22, "fail": 0},
"content": {"pass": 15, "fail": 0},
"notify": {"pass": 8, "fail": 0},
"home": {"pass": 6, "fail": 0},
"dedupe": {"pass": 9, "fail": 0},
"trending": {"pass": 11, "fail": 0},
"mute": {"pass": 9, "fail": 0},
"page": {"pass": 14, "fail": 0},
"thread": {"pass": 12, "fail": 0}
},
"total_pass": 189,
"total_fail": 0,
"total": 189
}

19
lib/feed/scoreboard.md Normal file
View File

@@ -0,0 +1,19 @@
# feed Conformance Scoreboard
_Generated by `lib/feed/conformance.sh`_
| Suite | Pass | Fail | Total |
|-------|-----:|-----:|------:|
| basic | 30 | 0 | 30 |
| fanout | 29 | 0 | 29 |
| rank | 24 | 0 | 24 |
| integration | 22 | 0 | 22 |
| content | 15 | 0 | 15 |
| notify | 8 | 0 | 8 |
| home | 6 | 0 | 6 |
| dedupe | 9 | 0 | 9 |
| trending | 11 | 0 | 11 |
| mute | 9 | 0 | 9 |
| page | 14 | 0 | 14 |
| thread | 12 | 0 | 12 |
| **Total** | **189** | **0** | **189** |

75
lib/feed/stream.sx Normal file
View File

@@ -0,0 +1,75 @@
; feed/stream — a stream is an APL vector (rank-1 array) whose ravel holds
; activity dicts. Operations lift APL primitives onto this shape: filter via
; compress (/), sort via grade (⍋), take via ↑, reverse via ⌽.
;
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx (loaded by harness).
(define feed/stream (fn (acts) (make-array (list (len acts)) acts)))
(define feed/items (fn (s) (get s :ravel)))
(define feed/count (fn (s) (len (get s :ravel))))
(define feed/empty (feed/stream (list)))
(define feed/empty? (fn (s) (= (feed/count s) 0)))
; filter — bool mask ∘ compress. pred : activity -> truthy
(define
feed/filter
(fn
(s pred)
(let
((items (get s :ravel)))
(let
((mask (make-array (list (len items)) (map (fn (a) (if (pred a) 1 0)) items))))
(apl-compress mask s)))))
; sort-by — ascending, stable on ties (grade-up is stable). key-fn : activity -> number
(define
feed/sort-by
(fn
(s key-fn)
(let
((items (get s :ravel)))
(let
((keys (make-array (list (len items)) (map key-fn items))))
(let
((order (get (apl-grade-up keys) :ravel)))
(feed/stream (map (fn (i) (nth items (- i 1))) order)))))))
(define feed/sort-by-at (fn (s) (feed/sort-by s feed/at)))
; newest-first: ascending sort then reverse (⌽)
(define feed/recent (fn (s) (apl-reverse (feed/sort-by-at s))))
; take N (↑), clamped to stream length so it never over-takes/pads
(define
feed/take
(fn
(s n)
(let
((c (feed/count s)))
(if (>= n c) s (apl-take (apl-scalar n) s)))))
(define feed/reverse (fn (s) (apl-reverse s)))
; common predicates
(define
feed/by-actor
(fn (s actor) (feed/filter s (fn (a) (equal? (get a :actor) actor)))))
(define
feed/by-verb
(fn (s verb) (feed/filter s (fn (a) (equal? (get a :verb) verb)))))
(define
feed/by-object
(fn
(s object)
(feed/filter s (fn (a) (equal? (get a :object) object)))))
; activities at or after timestamp t
(define
feed/since
(fn (s t) (feed/filter s (fn (a) (>= (get a :at) t)))))

118
lib/feed/tests/basic.sx Normal file
View File

@@ -0,0 +1,118 @@
; Phase 1 — normalize, stream ops, api. Uses the feed-test harness
; (feed-test name got expected) provided by conformance.sh.
; ---------- normalize ----------
(feed-test
"normalize default actor"
(feed/actor (feed/normalize {}))
"")
(feed-test
"normalize default verb"
(feed/verb (feed/normalize {}))
"post")
(feed-test
"normalize default at"
(feed/at (feed/normalize {}))
0)
(feed-test
"normalize default object"
(feed/object (feed/normalize {}))
nil)
(feed-test
"normalize default tags"
(feed/tags (feed/normalize {}))
(list))
(feed-test
"normalize keeps actor"
(feed/actor (feed/normalize {:actor "alice"}))
"alice")
(feed-test
"normalize keeps verb"
(feed/verb (feed/normalize {:verb "like"}))
"like")
(feed-test
"normalize scalar tag -> list"
(feed/tags (feed/normalize {:tags "x"}))
(list "x"))
(feed-test
"normalize list tags kept"
(feed/tags (feed/normalize {:tags (list "a" "b")}))
(list "a" "b"))
(feed-test
"activity constructor at"
(feed/at (feed/activity "a" "post" "o" 5 (list)))
5)
(feed-test
"activity? on activity"
(feed/activity? (feed/normalize {:actor "a"}))
true)
(feed-test "activity? on number" (feed/activity? 5) false)
(feed-test "activity? on bare dict" (feed/activity? {:foo 1}) false)
; ---------- stream ----------
(define
S
(feed/stream
(list
(feed/activity "alice" "post" "p1" 30 (list))
(feed/activity "bob" "like" "p1" 10 (list))
(feed/activity "alice" "post" "p2" 20 (list)))))
(feed-test "stream count" (feed/count S) 3)
(feed-test "stream items len" (len (feed/items S)) 3)
(feed-test
"sort-by-at actors asc"
(map feed/actor (feed/items (feed/sort-by-at S)))
(list "bob" "alice" "alice"))
(feed-test
"recent newest first"
(map feed/at (feed/items (feed/recent S)))
(list 30 20 10))
(feed-test
"take 2 of recent"
(feed/count (feed/take (feed/recent S) 2))
2)
(feed-test
"take clamps past end"
(feed/count (feed/take S 10))
3)
(feed-test
"by-actor alice count"
(feed/count (feed/by-actor S "alice"))
2)
(feed-test
"by-verb like actor"
(map feed/actor (feed/items (feed/by-verb S "like")))
(list "bob"))
(feed-test
"by-object p1 count"
(feed/count (feed/by-object S "p1"))
2)
(feed-test
"since 20 count"
(feed/count (feed/since S 20))
2)
(feed-test
"reverse ats"
(map feed/at (feed/items (feed/reverse S)))
(list 20 10 30))
(feed-test "empty? on empty" (feed/empty? feed/empty) true)
(feed-test
"empty? on filtered-out"
(feed/empty? (feed/by-actor S "zzz"))
true)
; ---------- api ----------
(feed/reset!)
(feed/post {:actor "x" :at 1 :verb "post"})
(feed/post {:actor "y" :at 2 :verb "like"})
(feed-test "api size after posts" (feed/size) 2)
(feed-test "api all count" (feed/count (feed/all)) 2)
(feed-test
"post returns normalized verb"
(feed/verb (feed/post {:actor "z"}))
"post")
(feed-test "api size after third post" (feed/size) 3)

85
lib/feed/tests/content.sx Normal file
View File

@@ -0,0 +1,85 @@
; Follow-up — TF-IDF content ranking over :tags. (feed-test name got expected)
(define
corpus
(feed/stream
(list
(feed/normalize {:actor "u" :object "o1" :at 10 :tags (list "cats" "funny")})
(feed/normalize {:actor "u" :object "o2" :at 20 :tags (list "cats" "news")})
(feed/normalize {:actor "u" :object "o3" :at 30 :tags (list "politics" "news")})
(feed/normalize {:actor "u" :object "o4" :at 40 :tags (list "cats")}))))
; ---------- document frequency ----------
(feed-test "df cats" (get (feed/tag-df corpus) "cats") 3)
(feed-test "df news" (get (feed/tag-df corpus) "news") 2)
(feed-test "df funny" (get (feed/tag-df corpus) "funny") 1)
(feed-test "df politics" (get (feed/tag-df corpus) "politics") 1)
(feed-test "df full" (feed/tag-df corpus) {:news 2 :funny 1 :politics 1 :cats 3})
; ---------- inverse document frequency ----------
(feed-test
"idf news = log(4/2)"
(get (feed/tag-idf corpus) "news")
(log 2))
(feed-test
"idf funny = log(4/1)"
(get (feed/tag-idf corpus) "funny")
(log 4))
(feed-test
"rarer tag has higher idf"
(>
(get (feed/tag-idf corpus) "funny")
(get (feed/tag-idf corpus) "cats"))
true)
; ---------- tf-idf scoring ----------
(define idf (feed/tag-idf corpus))
(feed-test
"score query funny on o1"
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats" "funny")}))
(log 4))
(feed-test
"score query funny on non-match"
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
0)
(feed-test
"unknown query tag scores 0"
((feed/tfidf-score idf (list "zzz")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
0)
; ---------- ranking by relevance ----------
; query news: o2,o3 match (score log2), o1,o4 don't (0); ties break by :at desc
(feed-test
"by-relevance news order"
(map
(fn (a) (get a :object))
(feed/items (feed/by-relevance corpus (list "news"))))
(list "o3" "o2" "o4" "o1"))
; query funny: only o1 matches -> ranks first
(feed-test
"by-relevance funny first"
(get
(nth (feed/items (feed/by-relevance corpus (list "funny"))) 0)
:object)
"o1")
; query (cats news): o2 carries both tags -> highest combined tf-idf
(feed-test
"by-relevance cats+news top"
(get
(nth
(feed/items (feed/by-relevance corpus (list "cats" "news")))
0)
:object)
"o2")
(feed-test
"by-relevance preserves count"
(feed/count (feed/by-relevance corpus (list "cats")))
4)

56
lib/feed/tests/dedupe.sx Normal file
View File

@@ -0,0 +1,56 @@
; Follow-up — verb-aware (smart) dedupe. (feed-test name got expected)
; reactions (like/follow) collapse cross-actor; posts stay distinct per actor
(define
M
(feed/stream
(list
(feed/activity "alice" "like" "X" 1 (list))
(feed/activity "bob" "like" "X" 2 (list))
(feed/activity "alice" "post" "P" 3 (list))
(feed/activity "bob" "post" "P" 4 (list))
(feed/activity "alice" "follow" "C" 5 (list))
(feed/activity "bob" "follow" "C" 6 (list))))) ; collapses
(feed-test
"smart dedupe total"
(feed/count (feed/dedupe-smart M))
4)
(feed-test
"smart keeps both posts"
(feed/count (feed/by-verb (feed/dedupe-smart M) "post"))
2)
(feed-test
"smart collapses likes to one"
(feed/count (feed/by-verb (feed/dedupe-smart M) "like"))
1)
(feed-test
"smart collapses follows to one"
(feed/count (feed/by-verb (feed/dedupe-smart M) "follow"))
1)
(feed-test
"collapsed like keeps first actor"
(map feed/actor (feed/items (feed/by-verb (feed/dedupe-smart M) "like")))
(list "alice"))
; contrast: plain activity dedupe keeps cross-actor likes distinct
(feed-test
"activity dedupe keeps both likes"
(feed/count (feed/by-verb (feed/dedupe-activities M) "like"))
2)
; contrast: blanket collapse folds the two posts (same verb+object) too
(feed-test
"collapse dedupe folds posts"
(feed/count (feed/by-verb (feed/dedupe-collapse M) "post"))
1)
; smart-key dispatch
(feed-test
"smart-key reaction -> (verb object)"
(feed/smart-key (feed/activity "alice" "like" "X" 0 (list)))
(list "like" "X"))
(feed-test
"smart-key post -> (actor verb object)"
(feed/smart-key (feed/activity "alice" "post" "P" 0 (list)))
(list "alice" "post" "P"))

187
lib/feed/tests/fanout.sx Normal file
View File

@@ -0,0 +1,187 @@
; Phase 2 — fanout via outer product + dedupe. (feed-test name got expected)
; ---------- graph ----------
; edges: (follower followee). bob,carol follow alice; carol,dave follow bob.
(define
G
(feed/follow-graph
(list
(list "bob" "alice")
(list "carol" "alice")
(list "carol" "bob")
(list "dave" "bob"))))
(feed-test "followers alice" (feed/followers G "alice") (list "bob" "carol"))
(feed-test "followers bob" (feed/followers G "bob") (list "carol" "dave"))
(feed-test "followers unknown" (feed/followers G "zzz") (list))
(feed-test "audience distinct" (feed/audience G) (list "bob" "carol" "dave"))
; ---------- fanout ----------
(define
S
(feed/stream
(list
(feed/activity "alice" "post" "p1" 10 (list))
(feed/activity "alice" "post" "p2" 20 (list))
(feed/activity "bob" "like" "p1" 30 (list)))))
(define IB (feed/fanout S G))
(feed-test "fanout total edges" (feed/count IB) 6)
(feed-test
"inbox bob count"
(feed/count (feed/inbox-for IB "bob"))
2)
(feed-test
"inbox carol count"
(feed/count (feed/inbox-for IB "carol"))
3)
(feed-test
"inbox dave count"
(feed/count (feed/inbox-for IB "dave"))
1)
(feed-test
"inbox alice (follows none)"
(feed/count (feed/inbox-for IB "alice"))
0)
(feed-test
"recipients order"
(feed/recipients IB)
(list "bob" "carol" "dave"))
(feed-test
"bob inbox objects"
(map (fn (a) (get a :object)) (feed/inbox-activities IB "bob"))
(list "p1" "p2"))
(feed-test
"dave inbox objects"
(map (fn (a) (get a :object)) (feed/inbox-activities IB "dave"))
(list "p1"))
(feed-test
"dave inbox verb"
(map (fn (a) (get a :verb)) (feed/inbox-activities IB "dave"))
(list "like"))
; empty graph → no audience → no edges
(feed-test
"empty graph fanout"
(feed/count (feed/fanout S {}))
0)
; actor nobody follows produces no edges
(define
Sghost
(feed/stream (list (feed/activity "ghost" "post" "g1" 5 (list)))))
(feed-test
"unfollowed actor fanout"
(feed/count (feed/fanout Sghost G))
0)
; ---------- high fanout (popular actor) ----------
(define
Gstar
(feed/follow-graph
(list
(list "u1" "star")
(list "u2" "star")
(list "u3" "star")
(list "u4" "star")
(list "u5" "star"))))
(define
Sstar
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
(feed-test
"star fanout count"
(feed/count (feed/fanout Sstar Gstar))
5)
(feed-test "star audience size" (len (feed/audience Gstar)) 5)
; ---------- mutual follow ----------
(define Gmut (feed/follow-graph (list (list "a" "b") (list "b" "a"))))
(define
Smut
(feed/stream
(list
(feed/activity "a" "post" "pa" 1 (list))
(feed/activity "b" "post" "pb" 2 (list)))))
(define IBmut (feed/fanout Smut Gmut))
(feed-test "mutual total" (feed/count IBmut) 2)
(feed-test
"mutual a gets pb"
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "a"))
(list "pb"))
(feed-test
"mutual b gets pa"
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "b"))
(list "pa"))
; ---------- dedupe ----------
(define
Sdup2
(feed/stream
(list
(feed/activity "alice" "post" "p1" 1 (list))
(feed/activity "alice" "post" "p1" 9 (list))
(feed/activity "alice" "post" "p2" 2 (list)))))
(feed-test
"dedupe-activities collapses dup"
(feed/count (feed/dedupe-activities Sdup2))
2)
(feed-test
"dedupe-activities keeps distinct"
(map
(fn (a) (get a :object))
(feed/items (feed/dedupe-activities Sdup2)))
(list "p1" "p2"))
(define
Slikes
(feed/stream
(list
(feed/activity "alice" "like" "X" 1 (list))
(feed/activity "bob" "like" "X" 2 (list))
(feed/activity "carol" "like" "Y" 3 (list)))))
(feed-test
"collapse cross-actor likes"
(feed/count (feed/dedupe-collapse Slikes))
2)
(feed-test
"collapse keeps distinct objects"
(map
(fn (a) (get a :object))
(feed/items (feed/dedupe-collapse Slikes)))
(list "X" "Y"))
(feed-test
"activity-key shape"
(feed/activity-key (feed/activity "a" "post" "o" 0 (list)))
(list "a" "post" "o"))
(feed-test
"collapse-key shape"
(feed/collapse-key (feed/activity "a" "like" "o" 0 (list)))
(list "like" "o"))
; cross-post: alice posts p1 twice → bob's inbox has it twice → dedupe-inbox → once
(define
Scross
(feed/stream
(list
(feed/activity "alice" "post" "p1" 1 (list))
(feed/activity "alice" "post" "p1" 5 (list)))))
(define IBcross (feed/fanout Scross G))
(feed-test
"cross-post raw bob count"
(feed/count (feed/inbox-for IBcross "bob"))
2)
(feed-test
"cross-post deduped bob count"
(feed/count (feed/inbox-for (feed/dedupe-inbox IBcross) "bob"))
1)
(feed-test
"dedupe-inbox keeps distinct receivers"
(feed/count (feed/dedupe-inbox IBcross))
2)

73
lib/feed/tests/home.sx Normal file
View File

@@ -0,0 +1,73 @@
; Follow-up — feed/home capstone pipeline. (feed-test name got expected)
; alice follows star and bob (edges: follower followee)
(define
G
(feed/follow-graph (list (list "alice" "star") (list "alice" "bob"))))
; star posts s1 then s2; bob posts b1; star re-posts s1 (cross-post dup);
; zoe posts z1 (alice does NOT follow zoe)
(define
S
(feed/stream
(list
(feed/activity "star" "post" "s1" 10 (list))
(feed/activity "star" "post" "s2" 20 (list))
(feed/activity "bob" "post" "b1" 15 (list))
(feed/activity "star" "post" "s1" 5 (list))
(feed/activity "zoe" "post" "z1" 30 (list)))))
(define rec (feed/recency 100 10))
(feed-test
"home count (deduped, followed only)"
(feed/count (feed/home S G "alice" feed/permit-public? rec 10))
3)
(feed-test
"home order by recency"
(map
(fn (a) (get a :object))
(feed/items (feed/home S G "alice" feed/permit-public? rec 10)))
(list "s2" "b1" "s1"))
(feed-test
"home excludes unfollowed zoe"
(feed/-elem?
"z1"
(map
(fn (a) (get a :object))
(feed/items (feed/home S G "alice" feed/permit-public? rec 10))))
false)
(feed-test
"home top-2"
(map
(fn (a) (get a :object))
(feed/items (feed/home S G "alice" feed/permit-public? rec 2)))
(list "s2" "b1"))
(feed-test
"home dedupes cross-post (one s1)"
(len
(filter
(fn (o) (equal? o "s1"))
(map
(fn (a) (get a :object))
(feed/items
(feed/home S G "alice" feed/permit-public? rec 10)))))
1)
; ACL applied per-viewer in the home pipeline
(define
Sacl
(feed/stream
(list (feed/normalize {:actor "star" :object "pub" :at 20}) (feed/normalize {:actor "star" :object "sec" :visible-to (list "carol") :at 25}))))
(define Gacl (feed/follow-graph (list (list "alice" "star"))))
(feed-test
"home hides activity alice not permitted"
(map
(fn (a) (get a :object))
(feed/items (feed/home Sacl Gacl "alice" feed/permit-acl? rec 10)))
(list "pub"))

View File

@@ -0,0 +1,155 @@
; Phase 4 — visibility (ACL) + federation, and the end-to-end timeline.
; (feed-test name got expected)
; ---------- ACL visibility ----------
; pub: public. sec: bob, allows carol. dm: frank, allows dave.
(define
C
(feed/stream
(list
(feed/normalize {:actor "alice" :object "pub" :at 10})
(feed/normalize {:actor "bob" :object "sec" :visible-to (list "carol") :at 20})
(feed/normalize {:actor "frank" :object "dm" :visible-to (list "dave") :at 30}))))
(feed-test
"public visible to anyone"
(feed/count (feed/visible C "zoe" feed/permit-acl?))
1)
(feed-test
"carol sees allowlisted + public"
(feed/count (feed/visible C "carol" feed/permit-acl?))
2)
(feed-test
"dave sees dm + public"
(feed/count (feed/visible C "dave" feed/permit-acl?))
2)
(feed-test
"author always sees own private"
(feed/count (feed/visible C "frank" feed/permit-acl?))
2)
(feed-test
"permit-public? lets all through"
(feed/count (feed/visible C "zoe" feed/permit-public?))
3)
(feed-test
"visible objects for dave"
(map
(fn (a) (get a :object))
(feed/items (feed/visible C "dave" feed/permit-acl?)))
(list "pub" "dm"))
; per-viewer: same stream, different timelines
(feed-test
"zoe timeline differs from carol"
(not
(=
(feed/count (feed/visible C "zoe" feed/permit-acl?))
(feed/count (feed/visible C "carol" feed/permit-acl?))))
true)
; ---------- federation: merge / ingest ----------
(define
L
(feed/stream
(list
(feed/activity "alice" "post" "p1" 10 (list))
(feed/activity "alice" "post" "p2" 20 (list)))))
(define
P
(feed/stream
(list
(feed/activity "alice" "post" "p2" 20 (list))
(feed/activity "peer" "post" "p9" 25 (list)))))
(feed-test "merge concatenates" (feed/count (feed/merge L P)) 4)
(feed-test
"ingest dedupes overlap"
(feed/count (feed/ingest L P))
3)
(feed-test
"inbound normalizes + ingests"
(feed/count (feed/inbound L (list {:actor "peer" :object "p9" :at 25} {:actor "alice" :object "p1" :at 10})))
3)
; backfill via injected fetch-fn
(define peer-history (fn (peer-id) (list {:actor peer-id :object "h1" :at 1} {:actor peer-id :object "h2" :at 2})))
(feed-test
"backfill merges peer history"
(feed/count (feed/backfill L peer-history "remote"))
4)
(feed-test
"backfill objects present"
(map
(fn (a) (get a :object))
(feed/items
(feed/by-actor (feed/backfill L peer-history "remote") "remote")))
(list "h1" "h2"))
; ---------- federation: outbound partition ----------
; bob (local), alice@remote + carol@remote (remote) follow star
(define
Gf
(feed/follow-graph
(list
(list "bob" "star")
(list "alice@remote" "star")
(list "carol@remote" "star"))))
(define
Sf
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
(define
remote?
(fn (id) (feed/-elem? id (list "alice@remote" "carol@remote"))))
(define parts (feed/federate Sf Gf remote?))
(feed-test "local deliveries" (feed/count (get parts :local)) 1)
(feed-test "remote deliveries" (feed/count (get parts :remote)) 2)
(feed-test
"local recipient is bob"
(feed/recipients (get parts :local))
(list "bob"))
; deliver: send-fn receives each remote event, local inbox returned
(define sent (list))
(define send-fn (fn (to act) (set! sent (append sent (list to)))))
(define local-inbox (feed/deliver Sf Gf remote? send-fn))
(feed-test "deliver returns local inbox" (feed/count local-inbox) 1)
(feed-test "deliver sent to both remotes" (len sent) 2)
(feed-test "deliver remote targets" sent (list "alice@remote" "carol@remote"))
; ---------- end-to-end: federated, ACL-filtered, ranked timeline ----------
(define
base
(feed/stream
(list
(feed/normalize {:actor "alice" :object "a1" :at 100})
(feed/normalize {:actor "bob" :object "b1" :visible-to (list "carol") :at 90})
(feed/normalize {:actor "eve" :object "e1" :visible-to (list "dave") :at 80}))))
(define federated (feed/inbound base (list {:actor "peer" :object "x1" :at 110})))
(define rec (feed/recency 120 10))
(define
carol-tl
(feed/timeline federated "carol" feed/permit-acl? rec 3))
; eve's :visible-to excludes carol -> filtered out; peer/alice public, bob allows carol
(feed-test "carol federated timeline count" (feed/count carol-tl) 3)
(feed-test
"carol timeline order (recency)"
(map (fn (a) (get a :object)) (feed/items carol-tl))
(list "x1" "a1" "b1"))
(feed-test
"eve dm excluded from carol"
(feed/-elem? "e1" (map (fn (a) (get a :object)) (feed/items carol-tl)))
false)
(feed-test
"dave sees eve dm not bob"
(map
(fn (a) (get a :object))
(feed/items
(feed/timeline federated "dave" feed/permit-acl? rec 5)))
(list "x1" "a1" "e1"))

68
lib/feed/tests/mute.sx Normal file
View File

@@ -0,0 +1,68 @@
; Follow-up — viewer mute/block filtering. (feed-test name got expected)
(define
S
(feed/stream
(list
(feed/normalize {:actor "alice" :object "P1" :at 1 :tags (list "news")})
(feed/normalize {:actor "bob" :object "P2" :at 2 :tags (list "spam")})
(feed/normalize {:actor "alice" :object "P3" :at 3 :tags (list "cats")})
(feed/normalize {:actor "carol" :object "P4" :at 4 :tags (list "news" "spam")}))))
; ---------- mute actors ----------
(feed-test
"mute bob drops his post"
(map
(fn (a) (get a :object))
(feed/items (feed/mute-actors S (list "bob"))))
(list "P1" "P3" "P4"))
(feed-test
"mute alice drops two"
(feed/count (feed/mute-actors S (list "alice")))
2)
(feed-test
"mute nobody keeps all"
(feed/count (feed/mute-actors S (list)))
4)
; ---------- mute tags ----------
(feed-test
"mute spam tag drops two"
(map
(fn (a) (get a :object))
(feed/items (feed/mute-tags S (list "spam"))))
(list "P1" "P3"))
(feed-test
"mute news+cats leaves spam-only"
(map
(fn (a) (get a :object))
(feed/items (feed/mute-tags S (list "news" "cats"))))
(list "P2"))
; ---------- mute objects ----------
(feed-test
"mute object P3 (thread mute)"
(feed/count (feed/mute-objects S (list "P3")))
3)
; ---------- combined prefs ----------
(feed-test
"apply-prefs actors + tags"
(map
(fn (a) (get a :object))
(feed/items (feed/apply-prefs S {:mute-actors (list "bob") :mute-tags (list "cats")})))
(list "P1" "P4"))
(feed-test
"apply-prefs empty keeps all"
(feed/count (feed/apply-prefs S {}))
4)
(feed-test
"apply-prefs all three filters"
(map
(fn (a) (get a :object))
(feed/items (feed/apply-prefs S {:mute-objects (list "P3") :mute-actors (list "carol") :mute-tags (list "spam")})))
(list "P1"))

69
lib/feed/tests/notify.sx Normal file
View File

@@ -0,0 +1,69 @@
; Follow-up — notification feed over an inbox. (feed-test name got expected)
; an inbox is a stream of {:to receiver :activity act} events
(define mk-ev (fn (to act) {:activity act :to to}))
(define
IB
(feed/stream
(list
(mk-ev "alice" (feed/activity "bob" "like" "P" 10 (list)))
(mk-ev "alice" (feed/activity "carol" "like" "P" 20 (list)))
(mk-ev "alice" (feed/activity "dave" "reply" "Q" 30 (list)))
(mk-ev "bob" (feed/activity "eve" "like" "R" 40 (list))))))
; ---------- raw notifications ----------
(feed-test
"alice notification count"
(feed/count (feed/notifications IB "alice"))
3)
(feed-test
"bob notification count"
(feed/count (feed/notifications IB "bob"))
1)
(feed-test
"zoe no notifications"
(feed/count (feed/notifications IB "zoe"))
0)
; ---------- verb filtering ----------
(feed-test
"alice likes only"
(feed/count (feed/notify-verbs IB "alice" (list "like")))
2)
(feed-test
"alice replies only"
(feed/count (feed/notify-verbs IB "alice" (list "reply")))
1)
(feed-test
"alice like+reply"
(feed/count (feed/notify-verbs IB "alice" (list "like" "reply")))
3)
(feed-test
"alice follow (none)"
(feed/count (feed/notify-verbs IB "alice" (list "follow")))
0)
; ---------- digest ----------
(define dig (feed/notify-digest IB "alice"))
(feed-test "digest group count" (len dig) 2)
(feed-test
"digest sorted by key (like|P before reply|Q)"
(map (fn (g) (get g :object)) dig)
(list "P" "Q"))
(feed-test
"like group actors"
(get (nth dig 0) :actors)
(list "bob" "carol"))
(feed-test "like group count" (get (nth dig 0) :count) 2)
(feed-test "like group verb" (get (nth dig 0) :verb) "like")
(feed-test "reply group count" (get (nth dig 1) :count) 1)
(feed-test
"reply group actors"
(get (nth dig 1) :actors)
(list "dave"))
(feed-test "empty digest for zoe" (feed/notify-digest IB "zoe") (list))

86
lib/feed/tests/page.sx Normal file
View File

@@ -0,0 +1,86 @@
; Follow-up — pagination (offset + cursor). (feed-test name got expected)
; ---------- offset / limit ----------
(define
O
(feed/stream
(list
(feed/activity "u" "post" "o1" 1 (list))
(feed/activity "u" "post" "o2" 2 (list))
(feed/activity "u" "post" "o3" 3 (list))
(feed/activity "u" "post" "o4" 4 (list))
(feed/activity "u" "post" "o5" 5 (list)))))
(feed-test
"page 1"
(map
(fn (a) (get a :object))
(feed/items (feed/page O 0 2)))
(list "o1" "o2"))
(feed-test
"page 2"
(map
(fn (a) (get a :object))
(feed/items (feed/page O 2 2)))
(list "o3" "o4"))
(feed-test
"page 3 (partial)"
(map
(fn (a) (get a :object))
(feed/items (feed/page O 4 2)))
(list "o5"))
(feed-test
"page past end empty"
(feed/count (feed/page O 10 2))
0)
(feed-test "page-count 5/2 = 3" (feed/page-count O 2) 3)
(feed-test "page-count 5/5 = 1" (feed/page-count O 5) 1)
; ---------- cursor (recency) ----------
(define
R
(feed/stream
(list
(feed/activity "u" "post" "a" 50 (list))
(feed/activity "u" "post" "b" 40 (list))
(feed/activity "u" "post" "c" 30 (list))
(feed/activity "u" "post" "d" 20 (list))
(feed/activity "u" "post" "e" 10 (list)))))
(define p1 (feed/page-before R 100 2))
(feed-test
"cursor page 1 newest first"
(map (fn (a) (get a :object)) (feed/items p1))
(list "a" "b"))
(feed-test "next cursor after page 1" (feed/next-cursor p1) 40)
(define p2 (feed/page-before R (feed/next-cursor p1) 2))
(feed-test
"cursor page 2"
(map (fn (a) (get a :object)) (feed/items p2))
(list "c" "d"))
(feed-test "next cursor after page 2" (feed/next-cursor p2) 20)
(define p3 (feed/page-before R (feed/next-cursor p2) 2))
(feed-test
"cursor page 3 (partial)"
(map (fn (a) (get a :object)) (feed/items p3))
(list "e"))
(feed-test
"empty page nil cursor"
(feed/next-cursor (feed/page-before R 5 2))
nil)
(feed-test
"after cursor loads newer"
(map
(fn (a) (get a :object))
(feed/items (feed/recent (feed/after R 30))))
(list "a" "b"))
(feed-test
"before cursor count"
(feed/count (feed/before R 30))
2)

160
lib/feed/tests/rank.sx Normal file
View File

@@ -0,0 +1,160 @@
; Phase 3 — aggregation + ranking. (feed-test name got expected)
; ---------- aggregation ----------
(define
A
(feed/stream
(list
(feed/activity "alice" "post" "p1" 5 (list))
(feed/activity "alice" "post" "p2" 15 (list))
(feed/activity "bob" "post" "p3" 25 (list))
(feed/activity "alice" "like" "p1" 35 (list)))))
(feed-test "actor-counts" (feed/actor-counts A) {:alice 3 :bob 1})
(feed-test "object-counts" (feed/object-counts A) {:p2 1 :p3 1 :p1 2})
(feed-test
"group-by actor alice len"
(len (get (feed/group-by A feed/actor) "alice"))
3)
(feed-test
"group-count empty"
(feed/group-count feed/empty feed/actor)
{})
; day bucketing
(define
D
(feed/stream
(list
(feed/activity "alice" "post" "p1" 5 (list))
(feed/activity "alice" "post" "p2" 8 (list))
(feed/activity "alice" "post" "p3" 12 (list)))))
(feed-test "feed/day floor" (feed/day 12 10) 1)
(feed-test "feed/day same bucket" (feed/day 8 10) 0)
(feed-test "by-actor-day" (feed/by-actor-day D 10) {:alice#0 2 :alice#1 1})
; ---------- recency ----------
(define rec (feed/recency 100 10))
(feed-test
"recency at=now -> 1"
(rec (feed/activity "x" "post" "o" 100 (list)))
1)
(feed-test
"recency age=hl -> .5"
(rec (feed/activity "x" "post" "o" 90 (list)))
0.5)
(feed-test
"recency age=2hl -> .25"
(rec (feed/activity "x" "post" "o" 80 (list)))
0.25)
; ---------- velocity ----------
(define vel (feed/velocity D 10))
(feed-test
"velocity burst (at=12)"
(vel (feed/activity "alice" "post" "z" 12 (list)))
3)
(feed-test
"velocity mid (at=8)"
(vel (feed/activity "alice" "post" "z" 8 (list)))
2)
(feed-test
"velocity first (at=5)"
(vel (feed/activity "alice" "post" "z" 5 (list)))
1)
(feed-test
"velocity other actor"
(vel (feed/activity "bob" "post" "z" 12 (list)))
0)
; ---------- engagement ----------
(define eng (feed/engagement A))
(feed-test
"engagement p1"
(eng (feed/activity "x" "post" "p1" 0 (list)))
2)
(feed-test
"engagement p2"
(eng (feed/activity "x" "post" "p2" 0 (list)))
1)
; ---------- composite ----------
(define
cmp1
(feed/composite (list (list 2 (fn (a) (get a :at))))))
(feed-test
"composite single part"
(cmp1 (feed/activity "x" "post" "o" 5 (list)))
10)
(define
cmp2
(feed/composite
(list
(list 2 (fn (a) (get a :at)))
(list 3 (fn (a) 1)))))
(feed-test
"composite two parts"
(cmp2 (feed/activity "x" "post" "o" 5 (list)))
13)
; ---------- ranking ----------
(define
R
(feed/stream
(list
(feed/activity "u" "post" "oC" 80 (list))
(feed/activity "u" "post" "oA" 100 (list))
(feed/activity "u" "post" "oB" 90 (list)))))
(feed-test
"rank by recency objects"
(map (fn (a) (get a :object)) (feed/items (feed/rank R rec)))
(list "oA" "oB" "oC"))
(feed-test
"top-2 by recency"
(map (fn (a) (get a :object)) (feed/items (feed/top R rec 2)))
(list "oA" "oB"))
(feed-test "top-2 count" (feed/count (feed/top R rec 2)) 2)
; constant score -> tiebreak by :at descending
(define
T
(feed/stream
(list
(feed/activity "u" "post" "f" 10 (list))
(feed/activity "u" "post" "g" 30 (list))
(feed/activity "u" "post" "h" 20 (list)))))
(feed-test
"tiebreak at-desc"
(map
(fn (a) (get a :object))
(feed/items (feed/rank T (fn (a) 0))))
(list "g" "h" "f"))
; equal score AND equal :at -> stable input order
(define
E
(feed/stream
(list
(feed/activity "u" "post" "first" 50 (list))
(feed/activity "u" "post" "second" 50 (list)))))
(feed-test
"stable equal-key input order"
(map
(fn (a) (get a :object))
(feed/items (feed/rank E (fn (a) 0))))
(list "first" "second"))
(feed-test
"with-scores attaches score"
(get (nth (feed/items (feed/with-scores R rec)) 1) :score)
1)
(feed-test "rank preserves count" (feed/count (feed/rank A rec)) 4)

49
lib/feed/tests/thread.sx Normal file
View File

@@ -0,0 +1,49 @@
; Follow-up — conversation threading via :reply-to closure. (feed-test name got expected)
(define
S
(feed/stream
(list
(feed/normalize {:actor "a" :object "root" :at 1})
(feed/normalize {:actor "b" :object "r1" :at 2 :verb "reply" :reply-to "root"})
(feed/normalize {:actor "c" :object "r2" :at 3 :verb "reply" :reply-to "root"})
(feed/normalize {:actor "d" :object "r3" :at 4 :verb "reply" :reply-to "r1"})
(feed/normalize {:actor "e" :object "x" :at 5}))))
; ---------- direct replies ----------
(feed-test "direct replies to root" (feed/reply-count S "root") 2)
(feed-test "direct replies to r1" (feed/reply-count S "r1") 1)
(feed-test "no replies to r3" (feed/reply-count S "r3") 0)
(feed-test
"replies objects to root"
(map (fn (a) (get a :object)) (feed/items (feed/replies S "root")))
(list "r1" "r2"))
; ---------- thread closure ----------
(feed-test
"thread objects root (transitive)"
(feed/thread-objects S "root")
(list "root" "r1" "r2" "r3"))
(feed-test
"thread root chronological"
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root")))
(list "root" "r1" "r2" "r3"))
(feed-test "thread size root" (feed/thread-size S "root") 4)
(feed-test
"thread excludes unrelated x"
(feed/-elem?
"x"
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root"))))
false)
; ---------- sub-thread ----------
(feed-test
"thread from r1 (sub-tree)"
(map (fn (a) (get a :object)) (feed/items (feed/thread S "r1")))
(list "r1" "r3"))
(feed-test "thread size r1" (feed/thread-size S "r1") 2)
(feed-test "leaf thread is itself" (feed/thread-size S "r3") 1)
(feed-test "unrelated thread is itself" (feed/thread-size S "x") 1)

View File

@@ -0,0 +1,82 @@
; Follow-up — trending objects/actors by recent activity. (feed-test name got expected)
; window (50,100]: X@60,X@70 (a), Y@80 (b), Z@90 (c); W@40 is too old
(define
S
(feed/stream
(list
(feed/activity "a" "post" "X" 60 (list))
(feed/activity "a" "post" "X" 70 (list))
(feed/activity "b" "post" "Y" 80 (list))
(feed/activity "c" "post" "Z" 90 (list))
(feed/activity "d" "post" "W" 40 (list)))))
; ---------- trending objects ----------
(feed-test
"trending count (3 in window)"
(len (feed/trending S 100 50 10))
3)
(feed-test
"trending top object"
(get
(nth (feed/trending S 100 50 10) 0)
:object)
"X")
(feed-test
"trending top count"
(get
(nth (feed/trending S 100 50 10) 0)
:count)
2)
(feed-test
"trending order (count desc, key asc tiebreak)"
(map
(fn (e) (get e :object))
(feed/trending S 100 50 10))
(list "X" "Y" "Z"))
(feed-test
"trending top-2"
(map
(fn (e) (get e :object))
(feed/trending S 100 50 2))
(list "X" "Y"))
(feed-test
"old object W excluded"
(feed/-elem?
"W"
(map
(fn (e) (get e :object))
(feed/trending S 100 50 10)))
false)
(feed-test
"narrow window keeps only newest"
(map
(fn (e) (get e :object))
(feed/trending S 100 15 10))
(list "Z"))
(feed-test
"empty window -> nothing"
(feed/trending S 100 5 10)
(list))
; ---------- trending actors ----------
(feed-test
"trending actor top"
(get
(nth (feed/trending-actors S 100 50 10) 0)
:actor)
"a")
(feed-test
"trending actor count"
(get
(nth (feed/trending-actors S 100 50 10) 0)
:count)
2)
(feed-test
"trending actors order"
(map
(fn (e) (get e :actor))
(feed/trending-actors S 100 50 10))
(list "a" "b" "c"))

59
lib/feed/thread.sx Normal file
View File

@@ -0,0 +1,59 @@
; feed/thread — conversation threading. A reply carries :reply-to <parent-object>
; (normalize preserves it). A thread is the transitive closure over :reply-to from
; a root object: root + replies + replies-to-replies, gathered chronologically.
;
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
; (feed/-elem?, feed/-distinct).
; direct replies to an object
(define
feed/replies
(fn
(stream object)
(feed/filter stream (fn (a) (equal? (get a :reply-to) object)))))
(define
feed/reply-count
(fn (stream object) (feed/count (feed/replies stream object))))
; iterate f from x until the result stops growing (set-closure fixpoint)
(define
feed/-fixpoint
(fn
(f x)
(let
((nx (f x)))
(if (= (len nx) (len x)) x (feed/-fixpoint f nx)))))
; the set of object-ids in the thread rooted at `root`
(define
feed/thread-objects
(fn
(stream root)
(let
((all (feed/items stream)))
(feed/-fixpoint
(fn
(acc)
(feed/-distinct
(append
acc
(map
(fn (a) (get a :object))
(filter (fn (a) (feed/-elem? (get a :reply-to) acc)) all)))))
(list root)))))
; the full thread as a chronological stream (root + all descendants)
(define
feed/thread
(fn
(stream root)
(let
((objs (feed/thread-objects stream root)))
(feed/sort-by-at
(feed/filter stream (fn (a) (feed/-elem? (get a :object) objs)))))))
; how many activities are in the thread (root counts as 1)
(define
feed/thread-size
(fn (stream root) (feed/count (feed/thread stream root))))

42
lib/feed/trending.sx Normal file
View File

@@ -0,0 +1,42 @@
; feed/trending — what's hot right now: objects (or actors) ranked by activity
; count within a recency window. Deterministic: count descending, ties broken by
; key ascending (entries are pre-sorted by key, then stable grade-down by count).
;
; Requires: lib/feed/stream.sx, lib/feed/aggregate.sx (object/actor-counts),
; lib/feed/rank.sx (feed/-desc-by).
; activities within (now-window, now]
(define
feed/-recent
(fn
(stream now window)
(feed/filter
stream
(fn (a) (and (<= (get a :at) now) (> (get a :at) (- now window)))))))
; counts dict -> top-N entries {label key, :count n}, count desc, key asc
(define
feed/-top-counts
(fn
(counts label n)
(let
((entries (map (fn (k) (assoc {:count (get counts k)} label k)) (sort (keys counts)))))
(take (feed/-desc-by entries (fn (e) (get e :count))) n))))
; top-N trending objects in the window
(define
feed/trending
(fn
(stream now window n)
(feed/-top-counts
(feed/object-counts (feed/-recent stream now window))
:object n)))
; top-N most active actors in the window
(define
feed/trending-actors
(fn
(stream now window n)
(feed/-top-counts
(feed/actor-counts (feed/-recent stream now window))
:actor n)))

40
lib/mod/activity.sx Normal file
View File

@@ -0,0 +1,40 @@
;; lib/mod/activity.sx — export decisions as ActivityPub-shaped events.
;;
;; The rose-ash platform propagates cross-domain effects as ActivityPub-shaped
;; activities. A moderation decision maps to a moderation verb so the rest of the
;; platform (and federated peers) can act on it: remove→Delete, ban→Block,
;; hide/escalate→Flag, keep→no activity. The precise mod action is preserved in
;; :action so a consumer can disambiguate (e.g. hide vs escalate, both Flag).
(define
mod/action->verb
(fn
(action)
(cond
((= action "remove") "Delete")
((= action "ban") "Block")
((= action "hide") "Flag")
((= action "escalate") "Flag")
(true nil))))
(define
mod/decision->activity
(fn
(d actor)
(let
((verb (mod/action->verb (get d :action))))
(if (nil? verb) nil {:type verb :action (get d :action) :actor actor :summary (str "moderation/" (get d :action) " via " (get d :rule)) :object (get d :report-id) :rule (get d :rule)}))))
;; map a batch of decisions to activities, dropping the no-op keeps
(define
mod/decisions->activities
(fn
(decisions actor)
(reduce
(fn
(acc d)
(let
((a (mod/decision->activity d actor)))
(if (nil? a) acc (append acc (list a)))))
(list)
decisions)))

163
lib/mod/api.sx Normal file
View File

@@ -0,0 +1,163 @@
;; lib/mod/api.sx — report registry + lifecycle façade + public entry points.
;;
;; mod/report files a report (assigning a sequential id) and opens a lifecycle
;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine
;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve,
;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its
;; states, logging each committed decision to the audit trail.
(define mod/*reports* (list))
(define mod/*cases* (list))
(define mod/*counter* 0)
(define mod/*rules* mod/default-rules)
(define
mod/reset!
(fn
()
(begin
(set! mod/*reports* (list))
(set! mod/*cases* (list))
(set! mod/*counter* 0)
(mod/audit-reset!))))
(define
mod/report
(fn
(by about reason)
(begin
(set! mod/*counter* (+ mod/*counter* 1))
(let
((id (str "r" mod/*counter*)))
(let
((r (mod/mk-report id by about reason)))
(begin
(append! mod/*reports* r)
(append! mod/*cases* {:id id :case (mod/mk-case r)})
r))))))
(define
mod/get-report
(fn
(id)
(reduce
(fn (acc r) (if (= (mod/report-id r) id) r acc))
nil
mod/*reports*)))
(define
mod/add-evidence
(fn
(id kind val)
(let
((r (mod/get-report id)))
(if
(nil? r)
nil
(let
((updated (mod/attach-evidence r (mod/mk-evidence kind val))))
(begin
(set!
mod/*reports*
(map
(fn (x) (if (= (mod/report-id x) id) updated x))
mod/*reports*))
updated))))))
(define
mod/decide
(fn
(id)
(let
((r (mod/get-report id)))
(if
(nil? r)
nil
(let
((d (mod/decide-report r mod/*reports* mod/*rules*)))
(begin (mod/log-decision! d (mod/report-evidence r)) d))))))
;; ── lifecycle façade over the case registry ──
(define
mod/case-of
(fn
(id)
(reduce
(fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc))
nil
mod/*cases*)))
(define
mod/case-store!
(fn
(id c)
(set!
mod/*cases*
(map
(fn (rec) (if (= (get rec :id) id) {:id id :case c} rec))
mod/*cases*))))
;; apply a lifecycle op to the stored case, persist it, and (when a decision was
;; committed cleanly) append it to the audit log; returns the updated case
(define
mod/case-apply!
(fn
(id op log?)
(let
((c (mod/case-of id)))
(if
(nil? c)
nil
(let
((c2 (op c)))
(begin
(mod/case-store! id c2)
(when
log?
(when
(nil? (mod/case-error c2))
(let
((d (mod/case-decision c2)))
(if
(nil? d)
nil
(mod/log-decision!
d
(mod/report-evidence (mod/case-report c2)))))))
c2))))))
(define
mod/triage
(fn
(id)
(mod/case-apply!
id
(fn (c) (mod/case-triage c mod/*reports* mod/*rules*))
false)))
(define
mod/resolve
(fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true)))
(define
mod/review
(fn
(id kind val)
(mod/case-apply!
id
(fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*))
true)))
(define
mod/appeal
(fn
(id kind val)
(mod/case-apply!
id
(fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*))
true)))
(define
mod/finalize
(fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false)))

54
lib/mod/audit.sx Normal file
View File

@@ -0,0 +1,54 @@
;; lib/mod/audit.sx — append-only decision log.
;;
;; Every decision the api commits is recorded as an immutable audit entry holding
;; the decision (action + matching rule), the proof tree (the derivation that
;; justified it), and a snapshot of the evidence in force at decision time. The
;; log is append-only: entries are never mutated or removed, only appended, each
;; with a monotonic sequence number. Retrieval is by report id (full history) or
;; by sequence.
(define mod/*audit-log* (list))
(define mod/*audit-seq* 0)
(define
mod/audit-reset!
(fn
()
(begin (set! mod/*audit-log* (list)) (set! mod/*audit-seq* 0))))
(define mod/mk-audit-entry (fn (seq decision evidence-snapshot) {:action (get decision :action) :evidence evidence-snapshot :proof (get decision :proof) :rule (get decision :rule) :report-id (get decision :report-id) :seq seq}))
(define
mod/log-decision!
(fn
(decision evidence-snapshot)
(begin
(set! mod/*audit-seq* (+ mod/*audit-seq* 1))
(let
((entry (mod/mk-audit-entry mod/*audit-seq* decision evidence-snapshot)))
(begin (append! mod/*audit-log* entry) entry)))))
;; entries for one report, in chronological (sequence) order
(define
mod/audit
(fn
(id)
(reduce
(fn
(acc e)
(if (= (get e :report-id) id) (append acc (list e)) acc))
(list)
mod/*audit-log*)))
(define mod/audit-all (fn () mod/*audit-log*))
(define mod/audit-count (fn () (len mod/*audit-log*)))
;; most recent decision logged for a report (nil if none)
(define
mod/audit-latest
(fn
(id)
(reduce
(fn (acc e) (if (= (get e :report-id) id) e acc))
nil
mod/*audit-log*)))

55
lib/mod/batch.sx Normal file
View File

@@ -0,0 +1,55 @@
;; lib/mod/batch.sx — batch triage + corpus analytics.
;;
;; Operational layer: decide a whole queue of reports at once, summarize the
;; outcomes by action, and measure which rules actually fire across a corpus.
;; mod/never-fired is the empirical complement to lint's static unreachable check
;; (Ext 5): lint finds rules that CAN'T fire by structure; never-fired finds rules
;; that DIDN'T fire on real data.
(define
mod/decide-batch
(fn
(reports rules)
(map (fn (r) (mod/decide-report r reports rules)) reports)))
(define
mod/count-action
(fn
(decisions action)
(reduce
(fn (acc d) (if (= (get d :action) action) (+ acc 1) acc))
0
decisions)))
(define mod/action-histogram (fn (decisions) {:keep (mod/count-action decisions "keep") :remove (mod/count-action decisions "remove") :escalate (mod/count-action decisions "escalate") :hide (mod/count-action decisions "hide") :ban (mod/count-action decisions "ban")}))
(define
mod/rule-fire-count
(fn
(decisions rule-name)
(reduce
(fn (acc d) (if (= (get d :rule) rule-name) (+ acc 1) acc))
0
decisions)))
(define
mod/rule-coverage
(fn
(reports rules)
(let
((decisions (mod/decide-batch reports rules)))
(map (fn (rule) {:rule (mod/rule-name rule) :fired (mod/rule-fire-count decisions (mod/rule-name rule))}) rules))))
(define
mod/never-fired
(fn
(reports rules)
(reduce
(fn
(acc c)
(if
(= (get c :fired) 0)
(append acc (list (get c :rule)))
acc))
(list)
(mod/rule-coverage reports rules))))

60
lib/mod/conformance.conf Normal file
View File

@@ -0,0 +1,60 @@
# Mod conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=mod
MODE=dict
PRELOADS=(
lib/guest/pratt.sx
lib/prolog/tokenizer.sx
lib/prolog/parser.sx
lib/prolog/runtime.sx
lib/prolog/query.sx
lib/prolog/compiler.sx
lib/mod/schema.sx
lib/mod/policy.sx
lib/mod/defrule.sx
lib/mod/engine.sx
lib/mod/explain.sx
lib/mod/severity.sx
lib/mod/offenders.sx
lib/mod/quorum.sx
lib/mod/trace.sx
lib/mod/whatif.sx
lib/mod/batch.sx
lib/mod/temporal.sx
lib/mod/sla.sx
lib/mod/wire.sx
lib/mod/activity.sx
lib/mod/policies.sx
lib/mod/pipeline.sx
lib/mod/lifecycle.sx
lib/mod/audit.sx
lib/mod/api.sx
lib/mod/fed.sx
lib/mod/link.sx
lib/mod/lint.sx
)
SUITES=(
"decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)"
"audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)"
"escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)"
"fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)"
"extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)"
"link:lib/mod/tests/link.sx:(mod-link-tests-run!)"
"lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)"
"severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)"
"offenders:lib/mod/tests/offenders.sx:(mod-offenders-tests-run!)"
"quorum:lib/mod/tests/quorum.sx:(mod-quorum-tests-run!)"
"trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)"
"whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)"
"batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)"
"temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)"
"sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)"
"wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)"
"disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)"
"activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)"
"policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)"
"defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)"
"pipeline:lib/mod/tests/pipeline.sx:(mod-pipeline-tests-run!)"
)

3
lib/mod/conformance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Thin wrapper — see lib/guest/conformance.sh and lib/mod/conformance.conf.
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

16
lib/mod/defrule.sx Normal file
View File

@@ -0,0 +1,16 @@
;; lib/mod/defrule.sx — ergonomic rule / ruleset construction.
;;
;; The roadmap sketched a (defrule action :when conditions) surface. Conditions
;; already evaluate to plain data, so this needs no macro — variadic functions
;; suffice: mod/defrule collects its trailing condition forms via &rest (dropping
;; the explicit outer (list ...)), and mod/ruleset assembles rules the same way.
;;
;; (mod/ruleset
;; (mod/defrule "spam-hide" :hide (list :classification "spam"))
;; (mod/defrule "default-keep" :keep))
(define
mod/defrule
(fn (name action &rest conds) (mod/mk-rule name action conds)))
(define mod/ruleset (fn (&rest rules) rules))

64
lib/mod/engine.sx Normal file
View File

@@ -0,0 +1,64 @@
;; lib/mod/engine.sx — decide a report by querying the policy program.
;;
;; build-program assembles the report's facts plus the compiled policy clauses;
;; decide-report runs the Prolog query and returns a decision. A decision is a
;; proof, not a bare keyword: it carries the matching rule, the conditions it
;; required, the evidence that satisfied them, and a derivation — the proof tree.
;;
;; The proof tree is built constructively: for the matching rule, each body goal
;; is re-queried against the same DB with the report id bound, recording the goal
;; text, whether it was solved, and the bindings that satisfied it. That is a
;; genuine derivation drawn from the Prolog database, ready for the audit trail.
(define
mod/find-rule
(fn
(rules name)
(reduce
(fn
(acc r)
(if (nil? acc) (if (= (mod/rule-name r) name) r acc) acc))
nil
rules)))
(define
mod/build-program
(fn
(r count rules)
(str (mod/report-facts r count) "\n" (mod/rules->program rules))))
(define
mod/proof-goals
(fn
(db id conds)
(if
(empty? conds)
(list {:solved true :goal "true" :bindings {}})
(map
(fn
(c)
(let
((g (mod/cond->goal c id)))
(let ((sols (pl-query-all db g))) {:solved (if (empty? sols) false true) :goal g :bindings (if (empty? sols) {} (first sols))})))
conds))))
(define
mod/decide-report
(fn
(r reports rules)
(let
((count (mod/report-count (mod/report-about r) reports))
(kinds (mod/classify-keywords r))
(id (mod/report-id r)))
(let
((program (mod/build-program r count rules)))
(let
((db (pl-load program)))
(let
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
(if
(nil? sol)
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"}
(let
((rname (dict-get sol "Rule")))
(let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname})))))))))

55
lib/mod/explain.sx Normal file
View File

@@ -0,0 +1,55 @@
;; lib/mod/explain.sx — human-readable proof explanation.
;;
;; Turns a decision (from mod/decide-report, or any audit entry) into a readable
;; multi-line "why": the action, the rule that fired, the evidence in play, and
;; the derivation goal-by-goal with [proved]/[unproved] marks and the unification
;; bindings that satisfied each goal. Pure SX over the Phase-2 proof tree.
(define
mod/explain-binds
(fn
(binds)
(mod/join-with
", "
(map (fn (k) (str k "=" (dict-get binds k))) (keys binds)))))
(define
mod/explain-goal
(fn
(g)
(let
((mark (if (get g :solved) " [proved] " " [unproved] "))
(binds (get g :bindings)))
(if
(empty? (keys binds))
(str mark (get g :goal))
(str mark (get g :goal) " {" (mod/explain-binds binds) "}")))))
(define
mod/explain-evidence
(fn
(evidence)
(if
(empty? evidence)
"Evidence: (none)"
(str "Evidence: " (mod/join-with ", " evidence)))))
(define
mod/explain
(fn
(decision)
(let
((id (get decision :report-id))
(action (get decision :action))
(rule (get decision :rule))
(proof (get decision :proof)))
(let
((goals (get proof :goals)) (evidence (get proof :evidence)))
(mod/join-with
"\n"
(append
(list
(str "Report " id ": " action " (rule: " rule ")")
(mod/explain-evidence evidence)
"Because:")
(map mod/explain-goal goals)))))))

145
lib/mod/fed.sx Normal file
View File

@@ -0,0 +1,145 @@
;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust,
;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire
;; transport would replace mod/fed-send!.
;;
;; Trust is advisory by default (the hard rule): a peer's decision only binds
;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is
;; recorded as a suggestion in the advisory log and is NOT applied. Local
;; decisions propagate outward via the outbox. Revocation undoes a locally
;; applied action when its proof is invalidated, notifying the origin peer.
(define mod/*fed-trust* (list)) ;; {:peer :scope}
(define mod/*fed-outbox* (list)) ;; {:to :type :payload}
(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied
(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked}
(define mod/*fed-origins* (list)) ;; {:id :origin}
(define
mod/fed-reset!
(fn
()
(begin
(set! mod/*fed-trust* (list))
(set! mod/*fed-outbox* (list))
(set! mod/*fed-advisory* (list))
(set! mod/*fed-applied* (list))
(set! mod/*fed-origins* (list)))))
;; ── trust model ──
(define
mod/trust-match?
(fn
(t peer scope)
(if (= (get t :peer) peer) (= (get t :scope) scope) false)))
(define
mod/grant-trust
(fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true)))
(define
mod/revoke-trust
(fn
(peer scope)
(set!
mod/*fed-trust*
(reduce
(fn
(acc t)
(if (mod/trust-match? t peer scope) acc (append acc (list t))))
(list)
mod/*fed-trust*))))
(define
mod/trusted?
(fn
(peer scope)
(mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*)))
;; ── cross-instance reports ──
(define
mod/fed-receive-report
(fn
(peer by about reason)
(let
((r (mod/report by about reason)))
(begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r))))
(define
mod/report-origin
(fn
(id)
(reduce
(fn (acc o) (if (= (get o :id) id) (get o :origin) acc))
"local"
mod/*fed-origins*)))
;; ── decision sharing (mock fed-sx send) ──
(define
mod/fed-send!
(fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true)))
(define mod/fed-outbox (fn () mod/*fed-outbox*))
(define
mod/fed-share-decision
(fn
(decision peers)
(reduce
(fn
(acc p)
(begin (mod/fed-send! p "decision" decision) (append acc (list p))))
(list)
peers)))
;; ── receiving a peer's decision (advisory unless trusted) ──
(define
mod/fed-applied-action
(fn
(report-id)
(reduce
(fn (acc a) (if (= (get a :report-id) report-id) a acc))
nil
mod/*fed-applied*)))
(define
mod/fed-receive-decision
(fn
(peer decision)
(if
(mod/trusted? peer :mod)
(begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision})
(begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision}))))
;; ── revocation ──
(define
mod/fed-revoke!
(fn
(report-id reason)
(begin
(set!
mod/*fed-applied*
(map
(fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a))
mod/*fed-applied*))
(mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason})
report-id)))
;; re-run the engine; if the action no longer holds, the prior decision's proof
;; is invalidated — revoke the applied moderation.
(define
mod/fed-revoke-if-invalidated
(fn
(report decision reports rules)
(let
((d2 (mod/decide-report report reports rules)))
(if
(= (get d2 :action) (get decision :action))
{:revoked false :decision d2}
(begin
(mod/fed-revoke! (get decision :report-id) "proof invalidated")
{:revoked true :decision d2})))))

160
lib/mod/lifecycle.sx Normal file
View File

@@ -0,0 +1,160 @@
;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine).
;;
;; Lifecycle state is deliberately separate from policy: the Prolog rules answer
;; "what action?", this module answers "where in the process is this report?".
;;
;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed
;; │ │
;; └────finalize───▶ :final ◀┘
;;
;; A case is an immutable value {:report :state :decision :tier :error :history}.
;; Every transition returns a NEW case; illegal transitions return the case
;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a
;; terminal action (hide/remove/keep) resolves immediately, an :escalate action
;; flags the case for human review (human-tier) before it can be resolved.
(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision}))
(define
mod/mk-case
(fn (report) (mod/case* report "open" nil nil nil (list))))
(define mod/case-report (fn (c) (get c :report)))
(define mod/case-state (fn (c) (get c :state)))
(define mod/case-decision (fn (c) (get c :decision)))
(define mod/case-tier (fn (c) (get c :tier)))
(define mod/case-error (fn (c) (get c :error)))
(define mod/case-history (fn (c) (get c :history)))
;; ── transition table ──
(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")})
(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst)))
(define
mod/lc-can-transition?
(fn
(from to)
(let
((outs (get mod/lc-transitions from)))
(if (nil? outs) false (mod/member? to outs)))))
;; ── core transition: validate, record history, or flag :error ──
(define
mod/case-goto
(fn
(c to note report decision tier)
(let
((from (mod/case-state c)))
(if
(mod/lc-can-transition? from to)
(mod/case*
report
to
decision
tier
nil
(append (mod/case-history c) (list {:note note :to to :from from})))
(mod/case*
(mod/case-report c)
from
(mod/case-decision c)
(mod/case-tier c)
(str "illegal transition: " from " -> " to)
(mod/case-history c))))))
(define
mod/case-error-set
(fn
(c msg)
(mod/case*
(mod/case-report c)
(mod/case-state c)
(mod/case-decision c)
(mod/case-tier c)
msg
(mod/case-history c))))
;; ── lifecycle operations ──
;; :open → :triaged — run the auto-tier first pass.
(define
mod/case-triage
(fn
(c reports rules)
(let
((d (mod/decide-report (mod/case-report c) reports rules)))
(let
((tier (if (= (get d :action) "escalate") "human" "auto")))
(mod/case-goto
c
"triaged"
"auto-tier first pass"
(mod/case-report c)
d
tier)))))
;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review.
(define
mod/case-resolve
(fn
(c)
(if
(= (mod/case-tier c) "human")
(mod/case-error-set c "awaiting human review (escalated)")
(mod/case-goto
c
"decided"
"auto-tier resolved"
(mod/case-report c)
(mod/case-decision c)
(mod/case-tier c)))))
;; :triaged → :decided — human review: attach evidence, re-decide, resolve.
(define
mod/case-review
(fn
(c kind val reports rules)
(let
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
(let
((d (mod/decide-report nr reports rules)))
(mod/case-goto c "decided" (str "human review: " kind) nr d "human")))))
;; :decided → :appealed — appeal: attach evidence, re-decide (may override).
(define
mod/case-appeal
(fn
(c kind val reports rules)
(let
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
(let
((d (mod/decide-report nr reports rules)))
(mod/case-goto
c
"appealed"
(str "appeal: " kind)
nr
d
(mod/case-tier c))))))
;; :decided | :appealed → :final
(define
mod/case-finalize
(fn
(c)
(mod/case-goto
c
"final"
"finalized"
(mod/case-report c)
(mod/case-decision c)
(mod/case-tier c))))
(define
mod/case-action
(fn
(c)
(let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action)))))

92
lib/mod/link.sx Normal file
View File

@@ -0,0 +1,92 @@
;; lib/mod/link.sx — report linking + deduplication.
;;
;; Reports about the same subject form a cluster; identical reports (same
;; reporter + subject + reason) are duplicates. Linking is Prolog-backed: all
;; report facts are loaded and related ids are found by unification — the same
;; relational substrate the policy engine uses, here for retrieval rather than
;; decision. Dedup is pure SX over a normalized link key.
(define
mod/link-key
(fn
(r)
(str
(mod/report-by r)
"|"
(mod/report-about r)
"|"
(downcase (mod/report-reason r)))))
(define
mod/dedup-reports
(fn
(reports)
(reduce
(fn
(acc r)
(if
(mod/any? (fn (x) (= (mod/link-key x) (mod/link-key r))) acc)
acc
(append acc (list r))))
(list)
reports)))
(define
mod/duplicate-count
(fn (reports) (- (len reports) (len (mod/dedup-reports reports)))))
;; ── Prolog-backed relational retrieval ──
(define
mod/report-rel-facts
(fn
(reports)
(mod/join-with
"\n"
(map
(fn
(r)
(str
"report("
(mod/report-id r)
", "
(mod/pl-quote (mod/report-by r))
", "
(mod/pl-quote (mod/report-about r))
")."))
reports))))
(define
mod/related-ids
(fn
(subject reports)
(let
((db (pl-load (mod/report-rel-facts reports))))
(map
(fn (sol) (dict-get sol "Id"))
(pl-query-all db (str "report(Id, _, " (mod/pl-quote subject) ")"))))))
(define
mod/reporters-of
(fn
(subject reports)
(let
((db (pl-load (mod/report-rel-facts reports))))
(map
(fn (sol) (dict-get sol "By"))
(pl-query-all db (str "report(_, By, " (mod/pl-quote subject) ")"))))))
(define
mod/distinct
(fn
(items)
(reduce
(fn
(acc x)
(if (mod/any? (fn (y) (= y x)) acc) acc (append acc (list x))))
(list)
items)))
(define
mod/distinct-reporters-of
(fn (subject reports) (mod/distinct (mod/reporters-of subject reports))))

69
lib/mod/lint.sx Normal file
View File

@@ -0,0 +1,69 @@
;; lib/mod/lint.sx — static analysis of a policy rule set.
;;
;; Because precedence is "first matching clause wins" (pl-query-one), the rule
;; order has correctness consequences a moderator can get wrong: a rule placed
;; after an unconditional (empty :when) rule can never fire, and a rule set with
;; no unconditional rule may leave some reports undecided. lint-rules surfaces
;; these without running the engine.
(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r))))
;; names of rules that follow the first unconditional rule — structurally dead,
;; since the unconditional rule always matches first
(define
mod/unreachable-rules
(fn
(rules)
(get
(reduce
(fn
(acc r)
(if
(get acc :hit)
{:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true}
(if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc)))
{:dead (list) :hit false}
rules)
:dead)))
(define
mod/has-catchall?
(fn (rules) (mod/any? mod/rule-unconditional? rules)))
(define
mod/count-eq
(fn
(x lst)
(reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst)))
(define
mod/duplicate-rule-names
(fn
(rules)
(let
((names (map mod/rule-name rules)))
(mod/distinct
(reduce
(fn
(acc n)
(if
(< 1 (mod/count-eq n names))
(append acc (list n))
acc))
(list)
names)))))
(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)}))
;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule
;; names are unique
(define
mod/rules-ok?
(fn
(rules)
(let
((l (mod/lint-rules rules)))
(if
(empty? (get l :unreachable))
(if (get l :has-catchall) (empty? (get l :duplicate-names)) false)
false))))

59
lib/mod/offenders.sx Normal file
View File

@@ -0,0 +1,59 @@
;; lib/mod/offenders.sx — repeat-offender escalation (audit log as evidence).
;;
;; The append-only audit trail is itself a source of evidence: a subject already
;; sanctioned several times is a repeat offender. mod/decide-escalating decides a
;; report normally, then — if the action is a sanction and the subject has at
;; least k PRIOR sanctions in the audit log — upgrades it to :ban. This is the one
;; place a decision depends on history beyond the single report, and it reads that
;; history from the audit log rather than re-deriving it.
(define
mod/sanction?
(fn
(action)
(mod/any? (fn (a) (= a action)) (list "hide" "remove" "ban"))))
;; count of prior sanctioning decisions in the audit log about a subject
(define
mod/subject-sanctions
(fn
(subject)
(reduce
(fn
(acc e)
(let
((r (mod/get-report (get e :report-id))))
(if
(nil? r)
acc
(if
(if
(= (mod/report-about r) subject)
(mod/sanction? (get e :action))
false)
(+ acc 1)
acc))))
0
(mod/audit-all))))
(define
mod/repeat-offender?
(fn (subject k) (<= k (mod/subject-sanctions subject))))
(define
mod/decide-escalating
(fn
(id k)
(let
((r (mod/get-report id)))
(if
(nil? r)
nil
(let
((priors (mod/subject-sanctions (mod/report-about r))))
(let
((d (mod/decide id)))
(if
(if (mod/sanction? (get d :action)) (<= k priors) false)
{:action "ban" :proof {:goals (get (get d :proof) :goals) :prior-sanctions priors :evidence (get (get d :proof) :evidence) :conditions (list) :rule "repeat-offender-ban" :count (get (get d :proof) :count)} :report-id id :rule "repeat-offender-ban" :strategy "escalating"}
d)))))))

18
lib/mod/pipeline.sx Normal file
View File

@@ -0,0 +1,18 @@
;; lib/mod/pipeline.sx — end-to-end triage orchestration.
;;
;; A single entry point that runs a report through the subsystem and returns the
;; full artifact bundle: the decision (under the report's domain policy), a
;; human-readable explanation, an ActivityPub-shaped event for the bus, and the
;; wire line for federated peers. Composes policies (Ext 17), explain (Ext 3),
;; activity (Ext 16) and wire (Ext 14) — the modules are independent, this is just
;; the convenience that wires them together for the common "process a report" path.
(define
mod/triage-pipeline
(fn
(domain r reports actor)
(let ((d (mod/decide-in domain r reports))) {:activity (mod/decision->activity d actor) :action (get d :action) :wire (mod/decision->wire d) :rule (get d :rule) :decision d :explanation (mod/explain d)})))
(define mod/pipeline-action (fn (p) (get p :action)))
(define mod/pipeline-activity (fn (p) (get p :activity)))
(define mod/pipeline-wire (fn (p) (get p :wire)))

40
lib/mod/policies.sx Normal file
View File

@@ -0,0 +1,40 @@
;; lib/mod/policies.sx — per-domain policy registry.
;;
;; rose-ash spans domains (blog, market, events, federation, …) that want
;; different moderation — a marketplace listing and a blog comment are not held to
;; the same bar. This registry maps a domain to a rule set; mod/decide-in resolves
;; the right policy and decides. Unregistered domains fall back to the default
;; rules, so adding a domain never leaves it unmoderated.
(define mod/*policies* (list))
(define mod/policies-reset! (fn () (set! mod/*policies* (list))))
(define
mod/register-policy!
(fn (domain rules) (begin (append! mod/*policies* {:domain domain :rules rules}) true)))
(define
mod/policy-registered?
(fn
(domain)
(mod/any? (fn (p) (= (get p :domain) domain)) mod/*policies*)))
(define
mod/policy-for
(fn
(domain)
(reduce
(fn (acc p) (if (= (get p :domain) domain) (get p :rules) acc))
mod/default-rules
mod/*policies*)))
(define
mod/decide-in
(fn
(domain r reports)
(mod/decide-report r reports (mod/policy-for domain))))
(define
mod/registered-domains
(fn () (map (fn (p) (get p :domain)) mod/*policies*)))

137
lib/mod/policy.sx Normal file
View File

@@ -0,0 +1,137 @@
;; lib/mod/policy.sx — moderation rules → Prolog clauses.
;;
;; A rule is {:name :action :when}. :when is a list of condition forms; each
;; compiles to a Prolog goal. The conditions in a :when list are ANDed (joined by
;; ", "); :not negates and :any (a list of sub-conditions) disjoins — so the
;; condition language is a small boolean algebra over the leaf predicates.
;; Rule order is precedence: the engine queries with pl-query-one, so the first
;; clause that proves wins. The final default rule has an empty body (true) so
;; every report yields at least :keep — "no rule matched" is a real result, not a
;; query failure.
;;
;; cond->goal takes an id-term so the same condition can be compiled with the
;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree
;; goal-by-goal re-querying in the engine).
;;
;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse
;; evidence (human review) > spam/abuse classification > repeated-report count >
;; default keep.
(define mod/mk-rule (fn (name action conds) {:when conds :name name :action action}))
(define mod/rule-name (fn (r) (get r :name)))
(define mod/rule-action (fn (r) (get r :action)))
(define mod/rule-when (fn (r) (get r :when)))
(define
mod/default-rules
(list
(mod/mk-rule
"exonerated-keep"
:keep (list (list :evidence "exonerated")))
(mod/mk-rule
"reviewer-remove"
:remove (list (list :evidence "confirmed-abuse")))
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
(mod/mk-rule
"abuse-remove"
:remove (list (list :classification "abuse")))
(mod/mk-rule
"repeated-escalate"
:escalate (list (list :count-at-least 3)))
(mod/mk-rule "default-keep" :keep (list))))
;; ── condition → Prolog goal ──
;;
;; (:classification "spam") → classification(Id, spam)
;; (:evidence "kind") → evidence(Id, 'kind', _)
;; (:attr "verified") → attr(Id, verified)
;; (:not <cond>) → not(<cond>) (negation)
;; (:any (list c1 c2 ...)) → (g1 ; g2 ; ...) (disjunction)
;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3
;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5
;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr),
;; length(Bsr, Nr), Nr >= 2 (quorum engine)
;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3
;; (temporal engine)
(define
mod/cond->goal
(fn
(c idterm)
(let
((tag (first c)))
(cond
((= tag :classification)
(str "classification(" idterm ", " (nth c 1) ")"))
((= tag :evidence)
(str
"evidence("
idterm
", "
(mod/pl-quote (nth c 1))
", _)"))
((= tag :attr) (str "attr(" idterm ", " (nth c 1) ")"))
((= tag :not)
(str "not(" (mod/cond->goal (nth c 1) idterm) ")"))
((= tag :any)
(str
"("
(mod/join-with
" ; "
(map
(fn (sub) (mod/cond->goal sub idterm))
(nth c 1)))
")"))
((= tag :count-at-least)
(str
"report("
idterm
", B, S), report_count(S, N), N >= "
(nth c 1)))
((= tag :score-at-least)
(str
"aggregate_all(sum(W), signal("
idterm
", _, W), T), T >= "
(nth c 1)))
((= tag :reporters-at-least)
(str
"report("
idterm
", _, Sr), setof(Br, report(_, Br, Sr), Bsr), "
"length(Bsr, Nr), Nr >= "
(nth c 1)))
((= tag :burst-at-least)
(str
"report("
idterm
", _, Sb), burst_count(Sb, Nb), Nb >= "
(nth c 1)))
(true "true")))))
(define
mod/conds->body
(fn
(conds idterm)
(if
(empty? conds)
"true"
(mod/join-with ", " (map (fn (c) (mod/cond->goal c idterm)) conds)))))
(define
mod/rule->clause
(fn
(r)
(str
"policy_action(Id, "
(mod/rule-action r)
", '"
(mod/rule-name r)
"') :- "
(mod/conds->body (mod/rule-when r) "Id")
".")))
(define
mod/rules->program
(fn (rules) (mod/join-with "\n" (map mod/rule->clause rules))))

40
lib/mod/quorum.sx Normal file
View File

@@ -0,0 +1,40 @@
;; lib/mod/quorum.sx — quorum decisions over distinct reporters (anti-brigade).
;;
;; The base engine asserts only the decided report's report/3 fact, so it can't
;; reason about WHO reported a subject. The quorum engine additionally asserts
;; every report's report/3 fact (via link's rel-facts), letting a rule require N
;; *distinct* reporters with `setof`/`length` — so one user filing many reports
;; does not manufacture consensus. Same decision shape as the base engine, plus
;; :strategy "quorum".
(define
mod/build-quorum-program
(fn
(r count reports rules)
(str
(mod/report-rel-facts reports)
"\n"
(mod/report-facts r count)
"\n"
(mod/rules->program rules))))
(define
mod/decide-quorum
(fn
(r reports rules)
(let
((count (mod/report-count (mod/report-about r) reports))
(kinds (mod/classify-keywords r))
(id (mod/report-id r)))
(let
((program (mod/build-quorum-program r count reports rules)))
(let
((db (pl-load program)))
(let
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
(if
(nil? sol)
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "quorum"}
(let
((rule (mod/find-rule rules (dict-get sol "Rule"))))
{:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "quorum"}))))))))

259
lib/mod/schema.sx Normal file
View File

@@ -0,0 +1,259 @@
;; lib/mod/schema.sx — report representation + Prolog fact generation.
;;
;; A report is a dict {:id :by :about :reason :evidence :attrs :signals :at}.
;; :evidence — accumulated {:kind :val} entries (human review, scanners)
;; :attrs — attribute names ("verified") for negation-as-failure conditions
;; :signals — weighted {:kind :weight} entries for aggregate scoring rules
;; :at — integer timestamp/tick (deterministic; supplied, not clock-read)
;; The engine derives keyword classifications from the reason text and projects
;; the report, its classifications, evidence, attributes, and signals into Prolog
;; facts that policy clauses match against.
(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :at 0 :reason reason}))
(define mod/report-id (fn (r) (get r :id)))
(define mod/report-by (fn (r) (get r :by)))
(define mod/report-about (fn (r) (get r :about)))
(define mod/report-reason (fn (r) (get r :reason)))
(define
mod/report-evidence
(fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e))))
(define
mod/report-attrs
(fn (r) (let ((a (get r :attrs))) (if (nil? a) (list) a))))
(define
mod/report-signals
(fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s))))
(define
mod/report-at
(fn (r) (let ((t (get r :at))) (if (nil? t) 0 t))))
(define mod/mk-evidence (fn (kind val) {:val val :kind kind}))
(define mod/evidence-kind (fn (e) (get e :kind)))
(define mod/evidence-val (fn (e) (get e :val)))
(define mod/mk-signal (fn (kind weight) {:kind kind :weight weight}))
(define mod/signal-kind (fn (s) (get s :kind)))
(define mod/signal-weight (fn (s) (get s :weight)))
(define mod/report* (fn (r evs attrs sigs at) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :at at :reason (mod/report-reason r)}))
(define
mod/with-evidence
(fn
(r evs)
(mod/report*
r
evs
(mod/report-attrs r)
(mod/report-signals r)
(mod/report-at r))))
(define
mod/with-attrs
(fn
(r attrs)
(mod/report*
r
(mod/report-evidence r)
attrs
(mod/report-signals r)
(mod/report-at r))))
(define
mod/with-signals
(fn
(r sigs)
(mod/report*
r
(mod/report-evidence r)
(mod/report-attrs r)
sigs
(mod/report-at r))))
(define
mod/with-at
(fn
(r at)
(mod/report*
r
(mod/report-evidence r)
(mod/report-attrs r)
(mod/report-signals r)
at)))
(define
mod/attach-evidence
(fn
(r e)
(mod/with-evidence r (append (mod/report-evidence r) (list e)))))
(define
mod/attach-attr
(fn (r a) (mod/with-attrs r (append (mod/report-attrs r) (list a)))))
(define
mod/attach-signal
(fn (r s) (mod/with-signals r (append (mod/report-signals r) (list s)))))
;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ──
(define
mod/contains-at?
(fn
(hay needle hl nl pos)
(if
(< hl (+ pos nl))
false
(if
(= (slice hay pos (+ pos nl)) needle)
true
(mod/contains-at? hay needle hl nl (+ pos 1))))))
(define
mod/str-contains?
(fn
(hay needle)
(let
((hl (len hay)) (nl (len needle)))
(if
(= nl 0)
true
(mod/contains-at? hay needle hl nl 0)))))
;; ── evidence derivation (keyword classification) ──
(define
mod/spam-keywords
(list "spam" "buy now" "click here" "free money" "viagra" "limited offer"))
(define
mod/abuse-keywords
(list "abuse" "harassment" "threat" "slur" "hate speech"))
(define
mod/any?
(fn (pred coll) (reduce (fn (acc x) (if acc acc (pred x))) false coll)))
(define
mod/reason-matches?
(fn
(reason kws)
(let
((low (downcase reason)))
(mod/any? (fn (k) (mod/str-contains? low k)) kws))))
(define
mod/classify-keywords
(fn
(r)
(let
((reason (mod/report-reason r)) (kinds (list)))
(begin
(when
(mod/reason-matches? reason mod/spam-keywords)
(append! kinds "spam"))
(when
(mod/reason-matches? reason mod/abuse-keywords)
(append! kinds "abuse"))
kinds))))
(define
mod/report-count
(fn
(about reports)
(reduce
(fn
(acc r)
(if (= (mod/report-about r) about) (+ acc 1) acc))
0
reports)))
;; ── Prolog fact projection ──
(define
mod/join-with
(fn
(sep items)
(reduce (fn (acc x) (if (= acc "") x (str acc sep x))) "" items)))
(define mod/pl-quote (fn (s) (str "'" s "'")))
(define
mod/classification-facts
(fn
(id kinds)
(mod/join-with
"\n"
(map (fn (k) (str "classification(" id ", " k ").")) kinds))))
(define
mod/evidence-facts
(fn
(id evs)
(mod/join-with
"\n"
(map
(fn
(e)
(str
"evidence("
id
", "
(mod/pl-quote (mod/evidence-kind e))
", "
(mod/pl-quote (str (mod/evidence-val e)))
")."))
evs))))
(define
mod/attr-facts
(fn
(id attrs)
(mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs))))
(define
mod/signal-facts
(fn
(id sigs)
(mod/join-with
"\n"
(map
(fn
(s)
(str
"signal("
id
", "
(mod/pl-quote (mod/signal-kind s))
", "
(mod/signal-weight s)
")."))
sigs))))
(define
mod/report-facts
(fn
(r count)
(let
((id (mod/report-id r))
(by (mod/pl-quote (mod/report-by r)))
(about (mod/pl-quote (mod/report-about r))))
(let
((cls (mod/classification-facts id (mod/classify-keywords r)))
(evs (mod/evidence-facts id (mod/report-evidence r)))
(ats (mod/attr-facts id (mod/report-attrs r)))
(sgs (mod/signal-facts id (mod/report-signals r))))
(mod/join-with
"\n"
(list
(str "report(" id ", " by ", " about ").")
(str "report_count(" about ", " count ").")
cls
evs
ats
sgs))))))

30
lib/mod/scoreboard.json Normal file
View File

@@ -0,0 +1,30 @@
{
"lang": "mod",
"total_passed": 390,
"total_failed": 0,
"total": 390,
"suites": [
{"name":"decide","passed":31,"failed":0,"total":31},
{"name":"audit","passed":29,"failed":0,"total":29},
{"name":"escalation","passed":46,"failed":0,"total":46},
{"name":"fed","passed":26,"failed":0,"total":26},
{"name":"extensions","passed":32,"failed":0,"total":32},
{"name":"link","passed":12,"failed":0,"total":12},
{"name":"lint","passed":14,"failed":0,"total":14},
{"name":"severity","passed":14,"failed":0,"total":14},
{"name":"offenders","passed":19,"failed":0,"total":19},
{"name":"quorum","passed":9,"failed":0,"total":9},
{"name":"trace","passed":15,"failed":0,"total":15},
{"name":"whatif","passed":13,"failed":0,"total":13},
{"name":"batch","passed":17,"failed":0,"total":17},
{"name":"temporal","passed":15,"failed":0,"total":15},
{"name":"sla","passed":15,"failed":0,"total":15},
{"name":"wire","passed":16,"failed":0,"total":16},
{"name":"disjunction","passed":10,"failed":0,"total":10},
{"name":"activity","passed":17,"failed":0,"total":17},
{"name":"policies","passed":14,"failed":0,"total":14},
{"name":"defrule","passed":11,"failed":0,"total":11},
{"name":"pipeline","passed":15,"failed":0,"total":15}
],
"generated": "2026-06-06T19:40:03+00:00"
}

Some files were not shown because too many files have changed in this diff Show More