diff --git a/next/kernel/outbox.erl b/next/kernel/outbox.erl index 5c3bd52e..b92b2994 100644 --- a/next/kernel/outbox.erl +++ b/next/kernel/outbox.erl @@ -92,12 +92,52 @@ publish(Request, Context) -> ok -> {ok, NewLog, _Seq} = log:append(LogState, Signed), broadcast(Signed, envelope_field(projections, Context)), - Result = [{cid, cid_of(Signed)}, {activity, Signed}], + DeliverySet = compute_delivery_set(Request, Signed, Context), + Result = [{cid, cid_of(Signed)}, + {activity, Signed}, + {delivery_set, DeliverySet}], {ok, Result, NewLog}; {error, Reason} -> {error, Reason, LogState} end. +%% compute_delivery_set/3 — Step 7c. Pulls the audience-resolved +%% recipient list off the Request's `:to` / `:cc` fields (the +%% envelope itself doesn't carry them — construct/4 only takes +%% type / actor / published / object). Context's optional +%% `:follower_graph` field carries a follower_graph state for +%% `public` / `followers` audience expansion; absent -> empty graph, +%% so explicit `:to` / `:cc` lists still resolve. Synthesises a +%% recipient-shaped envelope from Request + Signed so the existing +%% delivery:delivery_set/3 (which reads `:actor`, `:to`, `:cc`) can +%% process it as-is. +%% +%% Step 8's delivery-queue worker reads `{delivery_set, [ActorId, ...]}` +%% off the publish result and routes one HTTP POST per entry. + +compute_delivery_set(Request, Signed, Context) -> + Graph = case envelope_field(follower_graph, Context) of + nil -> follower_graph:new(); + G -> G + end, + Recipients = recipients_envelope(Request, Signed), + delivery:delivery_set(Recipients, [], Graph). + +recipients_envelope(Request, Signed) -> + Base = case envelope:get_field(actor, Signed) of + {ok, A} -> [{actor, A}]; + _ -> [] + end, + To = case envelope:get_field(to, Request) of + {ok, T} -> [{to, T}]; + _ -> [] + end, + Cc = case envelope:get_field(cc, Request) of + {ok, C} -> [{cc, C}]; + _ -> [] + end, + Base ++ To ++ Cc. + %% broadcast/2 — fire-and-forget cast to each named projection. %% Missing/nil/empty list is a no-op; the publish API does not %% require projections to exist. Activity is the post-sign Signed diff --git a/next/tests/outbox_publish.sh b/next/tests/outbox_publish.sh index e06675ee..dfa7410e 100755 --- a/next/tests/outbox_publish.sh +++ b/next/tests/outbox_publish.sh @@ -45,6 +45,10 @@ cat > "$TMPFILE" < "$TMPFILE" < same CID (epoch 18) (eval "(get (erlang-eval-ast \"${PRELUDE} {ok, R1, _} = outbox:publish(Req, Ctx), {ok, L0b} = log:open(alice, base), Ctx_b = [{actor_id,alice},{published,100},{key_spec,KS},{actor_state,AS},{log,L0b}], {ok, R2, _} = outbox:publish(Req, Ctx_b), {ok, C1} = envelope:get_field(cid, R1), {ok, C2} = envelope:get_field(cid, R2), C1 =:= C2\") :name)") + +;; Step 7c: Result has :delivery_set, empty when no :to/:cc + no graph +(epoch 20) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, R, _} = outbox:publish(Req, Ctx), envelope:get_field(delivery_set, R) =:= {ok, []}\") :name)") + +;; Step 7c: explicit :to -> delivery_set carries the recipient +(epoch 21) +(eval "(get (erlang-eval-ast \"${PRELUDE} ReqTo = [{type, note}, {object, [{content, hi}]}, {to, bob}], {ok, R, _} = outbox:publish(ReqTo, Ctx), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)") + +;; Step 7c: followers symbol expands via graph in Context +(epoch 22) +(eval "(get (erlang-eval-ast \"${PRELUDE} F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], Graph = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), CtxG = Ctx ++ [{follower_graph, Graph}], ReqFol = [{type, note}, {object, [{content, hi}]}, {to, followers}], {ok, R, _} = outbox:publish(ReqFol, CtxG), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)") + +;; Step 7c: self-suppression — alice's :to including alice drops it +(epoch 23) +(eval "(get (erlang-eval-ast \"${PRELUDE} ReqSelf = [{type, note}, {object, [{content, hi}]}, {to, [alice, bob]}], {ok, R, _} = outbox:publish(ReqSelf, Ctx), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)") EPOCHS -OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) +OUTPUT=$(timeout 480 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) check() { local epoch="$1" desc="$2" expected="$3" @@ -108,11 +128,15 @@ check() { check 2 "envelope module loaded" "envelope" check 3 "log module loaded" "log" check 4 "pipeline module loaded" "pipeline" -check 5 "outbox module loaded" "outbox" +check 7 "outbox module loaded" "outbox" check 10 "happy path tip advances to 1" "true" check 11 "result :cid matches activity" "true" check 12 "signed activity in log entries" "true" check 13 "duplicate publish -> replay" "ok" +check 20 "Result :delivery_set empty default" "true" +check 21 "explicit :to -> [bob] in set" "true" +check 22 "followers symbol expands via graph" "true" +check 23 "self-suppression on alice in :to" "true" check 14 "replay leaves log tip at 1" "true" check 15 "bad key material -> bad_signature" "ok" check 16 "distinct timestamps -> tip 2" "true" diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 9008c263..94fa5db4 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -503,10 +503,27 @@ expansion via the audience predicates from M1's genesis bundle. in the same audience deduplicates because both symbols expand identically. 19/19 in `delivery_set.sh` (2 new cases + 1 case updated from the v2 placeholder behavior). -- [ ] **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. +- [x] **7c** — Outbox-side integration. `outbox:publish/2` + now computes the delivery set after sign + log and stashes it + in the Result proplist as `{delivery_set, [ActorId, ...]}`. + Context's optional `:follower_graph` field carries a + follower_graph state for `public` / `followers` audience + expansion; absent -> empty graph (explicit `:to`/`:cc` + recipients still resolve). New helper + `compute_delivery_set/3(Request, Signed, Context)` and + `recipients_envelope/2` synthesise a minimal recipient + envelope from Request's `:to`/`:cc` + Signed's `:actor` so + `delivery:delivery_set/3` can process it unchanged + (outbox:construct/4 doesn't carry `:to`/`:cc` through the + envelope shape, and changing that surface would ripple to + every existing envelope test). Step 8's delivery-queue + worker will read `{delivery_set, [ActorId, ...]}` off the + publish result. 17/17 in `outbox_publish.sh` (+4 new cases: + empty-default, explicit-:to, followers-symbol-via-graph, + self-suppression). Module load chain rebumped from epoch 5 + to epoch 7 (adds follower_graph + delivery as dependencies) + and the test's internal sx_server timeout bumped 240s → + 480s to fit the larger module set. --- @@ -850,6 +867,23 @@ proceed. Newest first. +- **2026-06-07** — Step 7c (closes Step 7): outbox-side + delivery_set integration. `outbox:publish/2` computes the + audience-resolved delivery set after sign + log and stashes + it in the Result proplist as `{delivery_set, [ActorId, ...]}`. + New `compute_delivery_set/3(Request, Signed, Context)` + threads `:follower_graph` from Context through to + `delivery:delivery_set/3`. `recipients_envelope/2` synthesises + a minimal envelope from the Request's `:to`/`:cc` + Signed's + `:actor` so the existing delivery API works unchanged + (envelope construct/4 doesn't carry the audience fields + through). 17/17 in `outbox_publish.sh` (+4 new: empty-default, + explicit-:to, followers-symbol-via-graph, self-suppression). + Module load order shifted from epoch 5 to epoch 7 to make + room for follower_graph + delivery; internal sx_server + timeout bumped 240s → 480s. Step 7 fully closed (7a delivery + module + 7b public expansion + 7c outbox integration). + - **2026-06-06** — Step 7b: public audience expansion. `delivery:expand_audience(public, Sender, Graph)` now returns the sender's followers (same as `followers`) — per design