cross-domain slice 1: events as a fed-sx peer + allocate-a-post-to-a-calendar (LIVE)
The first cross-domain federated workflow — behaviors defined by TYPES, across domains. - events.rose-ash.com is now a fed-sx PEER: a lib/host instance with SX_DOMAIN=events whose 'calendar' TYPE declares an on-allocate behavior. Replaces the Python events service (no strangler). serve.sh gates domain types/behaviors on SX_DOMAIN (blog=article publish/digest; events=calendar+allocate). - DIRECTED cross-domain delivery: an activity with :to <peer-base> is delivered to that peer's inbox (∪ followers). The wire gains 'to'. So 'allocate' targets the events peer specifically. - host/blog--allocate-activity/allocate! + POST /:slug/allocate?calendar=<id>; the events calendar type's allocate-link DAG (an execute-fold effect) fires on receipt. - docker-compose: the sx_events service (own store, shared SX_FED_SECRET, externalnet for a future events.rose-ash.com Caddy route). LIVE PROOF: publish 'Gig Night' on blog.rose-ash.com → POST /gig-night/allocate?calendar=main → the events peer RECEIVES the directed, signed activity (/activities: 'allocate article gig-night') and its calendar type's on-allocate behavior FIRES (/flows: 'linked gig-night'). blog 218/218, full conformance green. NEXT: events runs lib/events (real calendars/recurrence/ticketing); link event→post; shop (lib/commerce) sells tickets — same federated, type-declared shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,12 @@ services:
|
||||
OCAMLRUNPARAM: "b"
|
||||
# TA-live ACTOR MODEL: A's actor identity + base URL. A is FOLLOWED (B follows it), so A has no
|
||||
# SX_FOLLOW; it delivers its activities to its followers. SX_FED_SECRET signs/verifies fed POSTs.
|
||||
SX_DOMAIN: "blog"
|
||||
SX_ACTOR: "blog.rose-ash.com"
|
||||
SX_SELF_URL: "http://sx_host:8000"
|
||||
SX_FED_SECRET: "rose-ash-fed-2026-shared-a3f9"
|
||||
# Cross-domain: where to send "allocate a post to a calendar" activities (the events peer).
|
||||
SX_EVENTS_BASE: "http://sx_events:8000"
|
||||
volumes:
|
||||
# SX source (hot-reload on container restart)
|
||||
- ./spec:/app/spec:ro
|
||||
@@ -82,13 +85,12 @@ services:
|
||||
- default
|
||||
restart: unless-stopped
|
||||
|
||||
# A second host instance — a federation PEER (B). Host A federates its emitted activities to B's
|
||||
# /inbox; B's engine fires ITS OWN behaviors on A's state changes ("everything works over fed-sx").
|
||||
# B has its own durable store + no peers (receives without re-federating). Reached on the default
|
||||
# network only (not exposed via Caddy).
|
||||
sx_host_b:
|
||||
# The EVENTS domain — a fed-sx peer running lib/host with SX_DOMAIN=events (a "calendar" type whose
|
||||
# on-allocate behavior links posts federated from blog). Replaces the Python events service. Blog
|
||||
# sends directed "allocate" activities to its /inbox. Own durable store; same shared fed secret.
|
||||
sx_events:
|
||||
image: registry.rose-ash.com:5000/sx_docs:latest
|
||||
container_name: sx-dev-sx_host_b-1
|
||||
container_name: sx-dev-sx_events-1
|
||||
entrypoint: ["bash", "/app/lib/host/serve.sh"]
|
||||
working_dir: /app
|
||||
environment:
|
||||
@@ -98,15 +100,13 @@ services:
|
||||
SX_HTTP_HOST: "0.0.0.0"
|
||||
SX_PERSIST_DIR: /data/persist
|
||||
SX_ADMIN_USER: admin
|
||||
SX_ADMIN_PASSWORD: "sx-host-b-camper-2026"
|
||||
SX_SESSION_SECRET: "ta-host-b-sess-9d2e1f"
|
||||
SX_ADMIN_PASSWORD: "sx-events-camper-2026"
|
||||
SX_SESSION_SECRET: "events-sess-9d2e1f"
|
||||
SX_SERVING_JIT: "1"
|
||||
OCAMLRUNPARAM: "b"
|
||||
# TA-live ACTOR MODEL: B's identity + base. B FOLLOWS A (SX_FOLLOW = A's base) at boot, so A
|
||||
# delivers its activities to B. Same shared SX_FED_SECRET so signatures verify across A↔B.
|
||||
SX_ACTOR: "sx_host_b"
|
||||
SX_SELF_URL: "http://sx_host_b:8000"
|
||||
SX_FOLLOW: "http://sx_host:8000"
|
||||
SX_DOMAIN: "events"
|
||||
SX_ACTOR: "events.rose-ash.com"
|
||||
SX_SELF_URL: "http://sx_events:8000"
|
||||
SX_FED_SECRET: "rose-ash-fed-2026-shared-a3f9"
|
||||
volumes:
|
||||
- ./spec:/app/spec:ro
|
||||
@@ -115,10 +115,9 @@ services:
|
||||
- ./web:/app/web:ro
|
||||
- ./shared/static:/app/shared/static:ro
|
||||
- ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro
|
||||
- /root/sx-host-b-persist:/data/persist
|
||||
- /root/sx-events-persist:/data/persist
|
||||
networks:
|
||||
# externalnet too, so a Caddy route (e.g. blog-b.rose-ash.com) can reverse_proxy to B — the
|
||||
# public-domain step is external Caddy + DNS config (not in this repo). default = A↔B fed traffic.
|
||||
# externalnet too, so Caddy can reverse_proxy events.rose-ash.com → here (external DNS/route).
|
||||
- externalnet
|
||||
- default
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -148,6 +148,16 @@
|
||||
:relation kind :target dst
|
||||
:delta (str verb " " kind " " dst)
|
||||
:id (str verb ":" src ":" kind ":" dst)}))
|
||||
;; ── CROSS-DOMAIN (events): allocate a post to a calendar — a DIRECTED activity (:to the events peer).
|
||||
;; It federates to events, whose calendar type declares an on-allocate behavior that links it.
|
||||
(define host/blog--events-base "") ;; the events peer base URL (serve-set from SX_EVENTS_BASE)
|
||||
(define host/blog--set-events-base! (fn (b) (set! host/blog--events-base b)))
|
||||
(define host/blog--allocate-activity
|
||||
(fn (post calendar)
|
||||
{:verb "allocate" :actor host/blog--actor
|
||||
:object post :object-type (host/blog--post-type post) :slug post
|
||||
:target calendar :to host/blog--events-base
|
||||
:delta (str "allocate to " calendar) :id (str "allocate:" post ":" calendar)}))
|
||||
;; MARSHAL the canonical activity → next/'s Erlang proplist shape, for the Erlang runner adapter
|
||||
;; (RA). The seam activity is canonical; each runner adapter maps it to its substrate. Unused until
|
||||
;; RA, defined + tested here so the reconcile is complete and RA has its bridge ready.
|
||||
@@ -173,7 +183,8 @@
|
||||
;; the ctx a publish activity presents to the publish-DAG (string keys — preds read ctx by key).
|
||||
;; Reads the canonical activity's top-level :category + :slug (P0.4).
|
||||
(define host/blog--publish-ctx
|
||||
(fn (activity) {"category" (get activity :category) "slug" (get activity :slug)}))
|
||||
(fn (activity) {"category" (get activity :category) "slug" (get activity :slug)
|
||||
"target" (get activity :target) "verb" (get activity :verb)}))
|
||||
|
||||
;; ── P1: types DECLARE behavior; the runner is DERIVED from the DAG's capabilities ──────
|
||||
;; A type-post carries :behavior = a list of flat string-keyed bindings {"verb" "type" "dag"} (like
|
||||
@@ -346,11 +357,13 @@
|
||||
(define host/blog--outbox-persist! (fn () (persist/backend-kv-put host/blog-store host/blog--outbox-key host/blog--outbox)))
|
||||
(define host/blog--enqueue-outbox!
|
||||
(fn (a)
|
||||
(begin
|
||||
(for-each (fn (base) (set! host/blog--outbox
|
||||
(concat host/blog--outbox (list {"peer" base "wire" (host/ta--serialize a)}))))
|
||||
(host/blog--delivery-bases))
|
||||
(host/blog--outbox-persist!))))
|
||||
(let ((targets (host/flow--uniq-concat (host/blog--delivery-bases)
|
||||
(if (and (get a :to) (not (= (get a :to) ""))) (list (get a :to)) (list)))))
|
||||
(begin
|
||||
(for-each (fn (base) (set! host/blog--outbox
|
||||
(concat host/blog--outbox (list {"peer" base "wire" (host/ta--serialize a)}))))
|
||||
targets)
|
||||
(host/blog--outbox-persist!)))))
|
||||
;; guarded, SIGNED delivery: POST the wire; a connection failure returns false (item kept), never raises.
|
||||
(define host/blog--try-deliver
|
||||
(fn (peer wire) (guard (e (true false)) (begin (host/blog--fed-post peer wire) true))))
|
||||
@@ -386,6 +399,8 @@
|
||||
;; a relation change → an Add/Remove activity (edge referenced, no CID shift).
|
||||
(define host/blog--emit-relation!
|
||||
(fn (verb src kind dst) (host/blog--emit! (host/blog--relation-activity verb src kind dst))))
|
||||
;; CROSS-DOMAIN: allocate a post to a calendar on the events peer (directed :to → federates to events).
|
||||
(define host/blog--allocate! (fn (post calendar) (host/blog--emit! (host/blog--allocate-activity post calendar))))
|
||||
|
||||
;; ── render ──────────────────────────────────────────────────────────
|
||||
;; A post's sx_content is SX element markup -> HTML via render-page (which supplies
|
||||
@@ -3022,10 +3037,19 @@
|
||||
(host/require-login resolve)
|
||||
(host/require-permission "edit" (fn (req) "blog")))
|
||||
h)))
|
||||
;; POST /<slug>/allocate?calendar=<id> — allocate a post to a calendar on the events peer. Emits a
|
||||
;; directed "allocate" activity that federates to events, whose calendar type reacts (P1 behavior).
|
||||
(define host/blog-allocate
|
||||
(fn (req)
|
||||
(let ((post (dream-param req "slug")) (calendar (or (dream-query-param req "calendar") "main")))
|
||||
(begin
|
||||
(when (not (= host/blog--events-base "")) (host/blog--allocate! post calendar))
|
||||
(dream-redirect (str "/" post "/"))))))
|
||||
(define host/blog-write-routes
|
||||
(fn (resolve)
|
||||
(list
|
||||
(dream-post "/new" (host/blog--protect-html resolve host/blog-form-submit))
|
||||
(dream-post "/:slug/allocate" (host/blog--protect-html resolve host/blog-allocate))
|
||||
(dream-get "/:slug/edit" (host/blog--protect-html resolve host/blog-edit-form))
|
||||
(dream-post "/:slug/edit" (host/blog--protect-html resolve host/blog-edit-submit))
|
||||
(dream-post "/:slug/blocks/add" (host/blog--protect-html resolve host/blog-block-add-submit))
|
||||
|
||||
@@ -222,14 +222,33 @@ EPOCH=1
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--register-dag! \\\"blog-digest\\\" {:erl-flow \\\"blog_digest\\\" :needs (list \\\"effect\\\" \\\"branch\\\" \\\"suspend\\\")})\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-type-behavior! \\\"article\\\" (list {\\\"verb\\\" \\\"create\\\" \\\"type\\\" \\\"article\\\" \\\"dag\\\" \\\"publish\\\"} {\\\"verb\\\" \\\"update\\\" \\\"type\\\" \\\"article\\\" \\\"dag\\\" \\\"blog-digest\\\"}))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
# Give the article type a "category" field so the edit form can set newsletter/urgent — the
|
||||
# branch the durable kernel flow keys on (newsletter suspends until resumed).
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-fields! \\\"article\\\" (list {:name \\\"category\\\" :type \\\"text\\\"}))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
# DOMAIN-SPECIFIC types + behaviors — behaviors are declared on TYPES; SX_DOMAIN picks which.
|
||||
if [ "${SX_DOMAIN:-blog}" = "events" ]; then
|
||||
# The EVENTS domain: a "calendar" type whose ON-ALLOCATE behavior links a post federated from
|
||||
# blog. When a directed "allocate" activity arrives at /inbox, the calendar behavior fires the
|
||||
# allocate-link DAG (an execute-fold effect). This is a cross-domain, type-declared reaction.
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog-seed! \\\"calendar\\\" \\\"Calendar\\\" \\\"(article (h1 \\\\\\\"Calendar\\\\\\\") (p \\\\\\\"Posts allocated to this calendar.\\\\\\\"))\\\" \\\"published\\\")\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--register-dag! \\\"allocate-link\\\" (quote (effect linked (field \\\"slug\\\"))))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-type-behavior! \\\"calendar\\\" (list {\\\"verb\\\" \\\"allocate\\\" \\\"type\\\" \\\"article\\\" \\\"dag\\\" \\\"allocate-link\\\"}))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
else
|
||||
# The BLOG domain: article create→publish (sync) + update→blog-digest (durable kernel) + a
|
||||
# "category" field for the edit form; and point at the events peer for cross-domain allocate.
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-type-behavior! \\\"article\\\" (list {\\\"verb\\\" \\\"create\\\" \\\"type\\\" \\\"article\\\" \\\"dag\\\" \\\"publish\\\"} {\\\"verb\\\" \\\"update\\\" \\\"type\\\" \\\"article\\\" \\\"dag\\\" \\\"blog-digest\\\"}))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-fields! \\\"article\\\" (list {:name \\\"category\\\" :type \\\"text\\\"}))\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
echo "(epoch $EPOCH)"
|
||||
echo "(eval \"(host/blog--set-events-base! \\\"${SX_EVENTS_BASE:-}\\\")\")"
|
||||
EPOCH=$((EPOCH+1))
|
||||
fi
|
||||
# P1: gather the types' declared :behavior bindings into the registry the trigger match
|
||||
# consults (so publishing an article fires its declared on-publish DAG, runner derived).
|
||||
echo "(epoch $EPOCH)"
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
(fn (a)
|
||||
{"verb" (get a :verb) "actor" (get a :actor) "object" (get a :object)
|
||||
"type" (get a :object-type) "slug" (get a :slug) "category" (get a :category)
|
||||
"relation" (get a :relation) "target" (get a :target)
|
||||
"relation" (get a :relation) "target" (get a :target) "to" (get a :to)
|
||||
"delta" (get a :delta) "id" (get a :id)}))
|
||||
(define host/ta--wire->activity
|
||||
(fn (w)
|
||||
{:verb (get w "verb") :actor (get w "actor") :object (get w "object")
|
||||
:object-type (get w "type") :slug (get w "slug") :category (get w "category")
|
||||
:relation (get w "relation") :target (get w "target")
|
||||
:relation (get w "relation") :target (get w "target") :to (get w "to")
|
||||
:delta (get w "delta") :id (get w "id")}))
|
||||
(define host/ta--serialize (fn (a) (serialize (host/ta--activity->wire a))))
|
||||
(define host/ta--deserialize (fn (s) (host/ta--wire->activity (parse-safe s))))
|
||||
|
||||
@@ -334,6 +334,17 @@ covers everything until a DAG's cost/latency/placement forces the substrate.
|
||||
activities), so business logic can change state, which federates, which triggers more flows.
|
||||
|
||||
## Progress log (newest first)
|
||||
- 2026-07-02 — CROSS-DOMAIN slice 1 DONE + LIVE-VERIFIED: allocate-a-post-to-a-calendar (blog→events).
|
||||
events.rose-ash.com is now a fed-sx PEER — a lib/host instance with SX_DOMAIN=events, whose
|
||||
"calendar" TYPE declares an on-allocate behavior (behaviors ARE type-declared — confirmed). Built:
|
||||
DIRECTED delivery (activity :to <peer> → delivered to that peer's inbox, in addition to followers;
|
||||
wire gains "to"); host/blog--allocate-activity/allocate! + POST /:slug/allocate?calendar=; serve.sh
|
||||
SX_DOMAIN gate (blog=article behaviors, events=calendar+allocate-link DAG); the sx_events container
|
||||
(own store, shared fed secret). LIVE: publish "Gig Night" on blog → allocate to calendar main → the
|
||||
events peer RECEIVES the directed activity (/activities) and its calendar type's on-allocate behavior
|
||||
FIRES (/flows "linked gig-night"). Signed + directed cross-domain federation, type-declared reaction.
|
||||
NEXT (the vision): events runs lib/events (real calendars/events/recurrence/ticketing); make "linked"
|
||||
a real relation/event; then link an event→post; then shop (lib/commerce) sells tickets. Same shape.
|
||||
- 2026-07-02 — FEDERATION PRODUCTION LAYER DONE + LIVE-VERIFIED (the actor model + the rest). (1)
|
||||
ACTOR MODEL: activities carry a real :actor (SX_ACTOR, not "site"); delivery is FOLLOWER-based, not
|
||||
a static peer list — a peer POSTs {verb:follow, actor, base} to /inbox to subscribe; B follows A at
|
||||
|
||||
Reference in New Issue
Block a user