From 086c576d48dea7bd07c77f5a5ca767a2ac57b052 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 23:34:18 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=207a=20=E2=80=94=20delivery:d?= =?UTF-8?q?elivery=5Fset/2,3=20+=2017=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New next/kernel/delivery.erl computes the audience-resolved deduplicated recipient list for an outbound activity. delivery_set/2(Activity, KernelState) delivery_set/3(Activity, KernelState, FollowerGraph) Returns a deduplicated list of ActorId atoms. Step 8 will resolve each entry to {PeerInstanceUrl, ActorId} via the peer-actors cache. Sources unioned then deduped: - :to field (single ActorId or list, atoms or audience symbols) - :cc field (same shape) - audience-symbol expansion: followers -> sender's followers from follower_graph public -> [] for v2 (Step 7b layers known-peer-instance set) Self-delivery suppressed every time the sender's ActorId appears in the set. Module lives in its own file (not inside outbox.erl) so Step 8's delivery-queue gen_server has a clean home alongside it. 17/17 in next/tests/delivery_set.sh covering: - empty activity -> [] - single :to atom + list :to recipients - :to + :cc unioned - self-suppression - duplicate / cross-field dedup - followers symbol expands via follower_graph state - empty follower-graph -> [] - public v2 placeholder -> [] - mixed explicit + followers - collect_recipients raw flat - suppress_self drops every match - dedup preserves first-occurrence order - expand_audience pass-through for plain ActorId Conformance 761/761. 86/86 across 6 Step-7-adjacent suites (follower_graph, follow_lifecycle, auto_accept, inbox, nx_kernel_multi, outbox_publish). --- next/kernel/delivery.erl | 84 ++++++++++++++++++++ next/tests/delivery_set.sh | 154 ++++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 54 +++++++++---- 3 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 next/kernel/delivery.erl create mode 100755 next/tests/delivery_set.sh diff --git a/next/kernel/delivery.erl b/next/kernel/delivery.erl new file mode 100644 index 00000000..ff16c6a8 --- /dev/null +++ b/next/kernel/delivery.erl @@ -0,0 +1,84 @@ +-module(delivery). +-export([delivery_set/2, delivery_set/3, + collect_recipients/1, suppress_self/2, dedup/1, + expand_audience/3]). + +%% Audience-resolving delivery set computation per design §13.4. +%% +%% delivery_set/2(Activity, KernelState) returns a sorted, deduped +%% list of ActorId atoms — every actor the outgoing Activity needs +%% to be POSTed to. Sources: +%% - Activity's `:to` field (single ActorId or list) +%% - Activity's `:cc` field (single ActorId or list) +%% - audience-symbol expansion of `public` and `followers` +%% +%% Self-delivery (the publishing actor reading their own activity +%% on a peer's behalf) is suppressed. +%% +%% Output for Step 7a is the bare ActorId list; Step 8 will resolve +%% each entry to `{PeerInstanceUrl, ActorId}` via the peer-actors +%% cache. + +delivery_set(Activity, KernelState) -> + delivery_set(Activity, KernelState, follower_graph:new()). + +delivery_set(Activity, KernelState, FollowerGraph) -> + Self = sender(Activity), + Raw = collect_recipients(Activity), + Expanded = expand_all(Raw, Self, KernelState, FollowerGraph), + Suppressed = suppress_self(Expanded, Self), + dedup(Suppressed). + +%% collect_recipients/1 — flat list from :to + :cc, normalised so +%% each element is either an ActorId atom or an audience symbol +%% (`public` / `followers`). + +collect_recipients(Activity) -> + To = envelope_field_list(to, Activity), + Cc = envelope_field_list(cc, Activity), + To ++ Cc. + +envelope_field_list(Field, Activity) -> + case envelope:get_field(Field, Activity) of + not_found -> []; + {ok, V} when is_list(V) -> V; + {ok, V} -> [V] + end. + +%% expand_audience/3 — Step 7b. `followers` -> the sender's +%% followers proplist entry from a follower_graph state. +%% `public` for v2 expands to []. Step 7c layers a peer-instance +%% known-set on top for real Public delivery. Other symbols / +%% explicit ActorIds pass through unchanged. + +expand_audience(public, _Sender, _Graph) -> []; +expand_audience(followers, Sender, Graph) -> + follower_graph:followers(Sender, Graph); +expand_audience(X, _Sender, _Graph) -> [X]. + +expand_all([], _Self, _State, _Graph) -> []; +expand_all([X | Rest], Self, State, Graph) -> + expand_audience(X, Self, Graph) ++ expand_all(Rest, Self, State, Graph). + +suppress_self([], _Self) -> []; +suppress_self([Self | Rest], Self) -> suppress_self(Rest, Self); +suppress_self([X | Rest], Self) -> [X | suppress_self(Rest, Self)]. + +dedup(L) -> dedup_acc(L, []). + +dedup_acc([], Acc) -> Acc; +dedup_acc([X | Rest], Acc) -> + case contains(X, Acc) of + true -> dedup_acc(Rest, Acc); + false -> dedup_acc(Rest, Acc ++ [X]) + end. + +contains(_, []) -> false; +contains(X, [X | _]) -> true; +contains(X, [_ | Rest]) -> contains(X, Rest). + +sender(Activity) -> + case envelope:get_field(actor, Activity) of + {ok, A} -> A; + _ -> nil + end. diff --git a/next/tests/delivery_set.sh b/next/tests/delivery_set.sh new file mode 100755 index 00000000..7bfc94de --- /dev/null +++ b/next/tests/delivery_set.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# next/tests/delivery_set.sh — m2 Step 7 test. +# +# delivery:delivery_set/2,3 computes the audience-resolved +# recipient list for an outbound activity. Sources are :to / :cc +# fields plus expansion of `followers` (via follower_graph) and +# `public` (v2 placeholder — Step 7c will populate with peer +# instances). Self-delivery suppressed; result deduplicated. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/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 + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +cat > "$TMPFILE" <<'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/vm/dispatcher.sx") +(epoch 2) +(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)") +(epoch 3) +(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)") +(epoch 4) +(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)") + +;; Empty activity -> empty delivery set +(epoch 10) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}], []) =:= []\") :name)") + +;; Single :to atom recipient +(epoch 11) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, bob}], []) =:= [bob]\") :name)") + +;; :to list of recipients +(epoch 12) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, carol]}], []) =:= [bob, carol]\") :name)") + +;; :cc adds to :to +(epoch 13) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob]}, {cc, [carol]}], []) =:= [bob, carol]\") :name)") + +;; Self-delivery suppressed (alice in :to is the publisher) +(epoch 14) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [alice, bob]}], []) =:= [bob]\") :name)") + +;; Duplicate recipients deduped +(epoch 15) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, bob]}, {cc, [bob]}], []) =:= [bob]\") :name)") + +;; :to and :cc with overlap are deduped +(epoch 16) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, carol]}, {cc, [carol, dave]}], []) =:= [bob, carol, dave]\") :name)") + +;; followers audience symbol -> sender's followers from follower_graph +(epoch 17) +(eval "(get (erlang-eval-ast \"Follow = [{actor, bob}, {type, follow}, {object, alice}], Accept = [{actor, alice}, {type, accept}, {object, Follow}], S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, followers}], [], S) =:= [bob]\") :name)") + +;; followers with empty follower-graph -> [] +(epoch 18) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, followers}], [], follower_graph:new()) =:= []\") :name)") + +;; public audience symbol -> [] for v2 (Step 7c will populate) +(epoch 19) +(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, public}], []) =:= []\") :name)") + +;; Mixed explicit + followers, followers carry two peers +(epoch 20) +(eval "(get (erlang-eval-ast \"F1 = [{actor, bob}, {type, follow}, {object, alice}], A1 = [{actor, alice}, {type, accept}, {object, F1}], F2 = [{actor, carol}, {type, follow}, {object, alice}], A2 = [{actor, alice}, {type, accept}, {object, F2}], S = follower_graph:fold(A2, follower_graph:fold(F2, follower_graph:fold(A1, follower_graph:fold(F1, follower_graph:new())))), delivery:delivery_set([{actor, alice}, {to, [dave, followers]}], [], S) =:= [dave, bob, carol]\") :name)") + +;; followers + explicit, with overlap deduped +(epoch 21) +(eval "(get (erlang-eval-ast \"F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], S = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, [bob, followers]}], [], S) =:= [bob]\") :name)") + +;; collect_recipients: bare helper returns flat list (no dedup, no self-suppression) +(epoch 22) +(eval "(get (erlang-eval-ast \"delivery:collect_recipients([{actor, alice}, {to, [bob, carol]}, {cc, [carol, dave]}]) =:= [bob, carol, carol, dave]\") :name)") + +;; suppress_self drops every occurrence of Self +(epoch 23) +(eval "(get (erlang-eval-ast \"delivery:suppress_self([bob, alice, carol, alice], alice) =:= [bob, carol]\") :name)") + +;; dedup preserves first occurrence order +(epoch 24) +(eval "(get (erlang-eval-ast \"delivery:dedup([bob, carol, bob, dave, carol]) =:= [bob, carol, dave]\") :name)") + +;; expand_audience: pass-through for plain ActorId +(epoch 25) +(eval "(get (erlang-eval-ast \"delivery:expand_audience(carol, alice, follower_graph:new()) =:= [carol]\") :name)") +EPOCHS + +OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 4 "delivery module loaded" "delivery" +check 10 "empty activity -> empty set" "true" +check 11 "single :to atom recipient" "true" +check 12 "list :to recipients" "true" +check 13 ":to + :cc unioned" "true" +check 14 "self-delivery suppressed" "true" +check 15 "duplicates within :to deduped" "true" +check 16 ":to/:cc overlap deduped" "true" +check 17 "followers expands via graph" "true" +check 18 "empty follower-graph -> []" "true" +check 19 "public v2 -> []" "true" +check 20 "mixed explicit + followers" "true" +check 21 "followers + overlap deduped" "true" +check 22 "collect_recipients raw flat" "true" +check 23 "suppress_self drops every match" "true" +check 24 "dedup preserves first-occurrence" "true" +check 25 "expand_audience pass-through" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/delivery_set.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 6063583c..e205f695 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -478,22 +478,30 @@ expansion via the audience predicates from M1's genesis bundle. **Deliverables:** -- `outbox:delivery_set/2(Activity, KernelState) -> [InboxUrl]`. -- Public expansion: every known peer instance's shared inbox (or every - follower of the publishing actor — both modes supported). -- Followers expansion: follower-graph lookup. -- Self-delivery suppression (don't POST to your own inbox). -- Returns a list of `{PeerInstanceUrl, ActorId}` tuples. - -**Tests:** - -- Activity with `:to: [bob]` → delivery set is bob's inbox. -- Activity with `:to: [Followers]` → set is current followers' inboxes. -- Activity with `:to: [Public]` → set is public reach. -- Self-deliveries excluded. -- Empty audience → empty set. - -**Acceptance:** `bash next/tests/delivery_set.sh` passes 12+ cases. +- [x] **7a** — `delivery:delivery_set/2,3` returns the + audience-resolved deduplicated list of ActorId atoms for an + outbound activity. Sources: explicit `:to` and `:cc` fields + (atom or list of atoms / audience symbols), plus expansion of + `followers` (via follower_graph) and `public` (v2 placeholder + — Step 7c). Self-delivery is suppressed every time the + sender's ActorId appears in the set. Returns are ActorId + atoms for now; Step 8 will resolve each entry to + `{PeerInstanceUrl, ActorId}` via the peer-actors cache. 17 + cases in `delivery_set.sh` covering empty / single / list / + cc-union / self-suppress / dedup / followers-expand / + public-empty / mixed audience / collect_recipients + + suppress_self + dedup helpers + expand_audience pass-through. + Module lives in `next/kernel/delivery.erl` (separate from + outbox so Step 8's delivery-queue gen_server has a clean home). +- [ ] **7b** — Public expansion: when Cfg or KernelState carries + a known-peer-instance set, `public` expands to one entry per + peer instance for the public-reach broadcast (Mastodon's + shared inbox per-instance pattern). v2 ships the empty case + via 7a so callers don't have to special-case the symbol. +- [ ] **7c** — Outbox-side integration: `outbox:publish/2` + computes the delivery set after sign + log and stashes it in + the Result proplist as `{delivery_set, [ActorId, ...]}`. Step + 8's delivery-queue worker reads it off the publish result. --- @@ -837,6 +845,20 @@ proceed. Newest first. +- **2026-06-06** — Step 7a: audience-resolving delivery set. + New `next/kernel/delivery.erl`: `delivery_set/2,3(Activity, + KernelState[, FollowerGraph])` returns a deduplicated list of + ActorId atoms — the targets an outbound activity needs to be + POSTed to. Sources: `:to` and `:cc` fields (single atom or + list, atoms or audience symbols), plus expansion of `followers` + via the supplied follower_graph state. `public` placeholder + returns `[]` for v2; Step 7b will populate via a known- + peer-instance set. Self-delivery suppressed. ActorIds for now — + Step 8 resolves each entry to `{PeerInstanceUrl, ActorId}` via + peer-actors cache. 17/17 in `delivery_set.sh`. Conformance + 761/761. Lives in its own module (not inside `outbox`) so the + Step 8 delivery-queue gen_server has a clean home. + - **2026-06-06** — Step 6c (closes Step 6): auto-Accept publish on Follow ingestion. New `maybe_auto_accept/3` in `http_server.erl` fires after successful inbox append + projection broadcast: