From 355bcbefdc694aa8c5c9b243ed6255adced1979b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 2 Jul 2026 20:25:37 +0000 Subject: [PATCH] cross-domain slice 1: events as a fed-sx peer + allocate-a-post-to-a-calendar (LIVE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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=; 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 --- docker-compose.dev-sx-host.yml | 31 +++++++++++++------------- lib/host/blog.sx | 36 +++++++++++++++++++++++++------ lib/host/serve.sh | 35 +++++++++++++++++++++++------- lib/host/ta.sx | 4 ++-- plans/business-logic-fed-flows.md | 11 ++++++++++ 5 files changed, 85 insertions(+), 32 deletions(-) diff --git a/docker-compose.dev-sx-host.yml b/docker-compose.dev-sx-host.yml index 36dacf1f..394831fc 100644 --- a/docker-compose.dev-sx-host.yml +++ b/docker-compose.dev-sx-host.yml @@ -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 diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 0fbebd61..919333e4 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -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 //allocate?calendar= — 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)) diff --git a/lib/host/serve.sh b/lib/host/serve.sh index 9dff91f1..cd87d268 100755 --- a/lib/host/serve.sh +++ b/lib/host/serve.sh @@ -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)" diff --git a/lib/host/ta.sx b/lib/host/ta.sx index ec49f586..1bc8948b 100644 --- a/lib/host/ta.sx +++ b/lib/host/ta.sx @@ -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)))) diff --git a/plans/business-logic-fed-flows.md b/plans/business-logic-fed-flows.md index 04a514b3..5441a8fd 100644 --- a/plans/business-logic-fed-flows.md +++ b/plans/business-logic-fed-flows.md @@ -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 → 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