From ee8a396ccd9904aaab941d44423a614f43191bae Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 22:46:52 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=206c=20=E2=80=94=20auto-Accep?= =?UTF-8?q?t=20on=20Follow=20ingestion=20+=209=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design §13.2 the v2 Follow policy is open-world: every successfully-ingested Follow triggers an Accept publish from the target actor. Enabled per-Cfg via {auto_accept_follows, true} so manual-moderation deployments can leave it off; default off. http_server.erl run_inbox_pipeline gained maybe_auto_accept/3: maybe_auto_accept(TargetAtom, Activity, Cfg) -> case field(auto_accept_follows, Cfg) of true -> case envelope:get_field(type, Activity) of {ok, follow} -> Req = [{type, accept}, {object, Activity}], nx_kernel:publish_to(TargetAtom, Req); _ -> ok end; _ -> ok end. The publish routes through the full outbox pipeline (envelope construct + HMAC sign + log append + outbox projection broadcast). When the target's outbox :projections list shares the same follower_graph projection that inbox broadcasts into, the bilateral relationship fold-converges automatically — alice.followers = [bob] and bob.following = [alice], both pending lists clear. No extra test scaffolding needed because outbox:publish already runs the broadcast hook from Step 7c. Bad-sig and non-Follow ingestion short-circuit before the Accept attempt (the validation pipeline rejects before run_inbox_pipeline's ok branch fires). 9/9 in next/tests/auto_accept.sh: - auto_accept on: alice's outbox tip advances to 1 - alice's outbox entry has :type = accept - follower_graph converges to {alice.followers=[bob], bob.following=[alice]} - both sides' pending lists clear after the Accept fold - auto_accept off (default): outbox stays empty; pending_inbound still gets populated from the Step 6b inbox-projection path, but alice.followers stays empty until human moderation acts - non-Follow ingestion (Create{Note}) with auto_accept on: no Accept published - bad-sig Follow with auto_accept on: no Accept (sig short-circuit in pipeline before maybe_auto_accept runs) Step 6 fully closed (6a follower_graph projection, 6b inbox -> projection broadcast wiring, 6c auto-Accept publish). Conformance 761/761. 89/89 across 7 Step-6-adjacent suites (inbox, inbox_peer_resolution, follower_graph, follow_lifecycle, auto_accept, http_publish, nx_kernel_multi). --- next/kernel/http_server.erl | 26 +++++++ next/tests/auto_accept.sh | 136 ++++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 38 ++++++++-- 3 files changed, 193 insertions(+), 7 deletions(-) create mode 100755 next/tests/auto_accept.sh diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 8a41a956..7d032af5 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -1103,12 +1103,38 @@ run_inbox_pipeline(TargetAtom, Activity, PeerAS, InboxLog, Cfg) -> ok -> nx_kernel:append_inbox(TargetAtom, Activity), broadcast_to_inbox_projections(Activity, Cfg), + maybe_auto_accept(TargetAtom, Activity, Cfg), actor_inbox_post_response(); {error, bad_signature} -> unauthorized_response(); {error, no_signature} -> unauthorized_response(); {error, _} -> validation_failed_response() end. +%% maybe_auto_accept/3 — Step 6c. Per design §13.2 the v2 default +%% Follow policy is open-world: every successfully-ingested Follow +%% triggers an Accept publish from the target actor. Enabled per-Cfg +%% via `{auto_accept_follows, true}` so callers that prefer manual +%% moderation can leave it off (manual moderation queue is v3). +%% +%% The Accept's `:object` is the original Follow envelope as +%% received — peers will use that to identify which Follow was +%% accepted. The publish goes through nx_kernel:publish_to/2 which +%% routes through the full outbox pipeline (construct + sign + log +%% + projection broadcast), so the target's outbox projections see +%% the Accept too. + +maybe_auto_accept(TargetAtom, Activity, Cfg) -> + case field(auto_accept_follows, Cfg) of + true -> + case envelope:get_field(type, Activity) of + {ok, follow} -> + AcceptRequest = [{type, accept}, {object, Activity}], + nx_kernel:publish_to(TargetAtom, AcceptRequest); + _ -> ok + end; + _ -> ok + end. + %% broadcast_to_inbox_projections/2 — Step 6b. Cfg may carry %% `{inbox_projections, [Name, ...]}` listing projection gen_servers %% that should see every successfully-ingested inbound activity. diff --git a/next/tests/auto_accept.sh b/next/tests/auto_accept.sh new file mode 100755 index 00000000..2d3e3614 --- /dev/null +++ b/next/tests/auto_accept.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# next/tests/auto_accept.sh — m2 Step 6c test. +# +# Per design §13.2 the v2 Follow policy is open-world: every +# successfully-ingested Follow triggers an Accept publish from the +# target actor. Enabled per-Cfg via {auto_accept_follows, true}; +# off by default so manual-moderation deployments can opt out. + +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 + +# Alice is on this kernel (target). Bob is the peer (signs Follow +# with BobKS). Alice's outbox projection is `followers` so when +# alice publishes the Accept, it folds through follower_graph too — +# both sides of the relationship update without any test scaffolding. +SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], FollowEnv = outbox:construct(follow, bob, 1, alice), SignedFollow = outbox:sign(FollowEnv, BKS), Body = term_codec:encode(SignedFollow), projection:start_link(followers, follower_graph:new(), follower_graph:fold_fn()), nx_kernel:start_link(alice, AKS, AAS), nx_kernel:with_projections_for(alice, [followers]), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}, {auto_accept_follows, true}], InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>,' + +cat > "$TMPFILE" <>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), nx_kernel:log_tip_for(alice)\")") + +;; auto_accept on: alice's outbox entry is an Accept activity +(epoch 21) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), {ok, L} = nx_kernel:log_state_for(alice), [E] = log:entries(L), envelope:get_field(type, E) =:= {ok, accept}\") :name)") + +;; auto_accept on: follower_graph state converges to full Follow relationship +;; (alice.followers = [bob], bob.following = [alice]) after both inbox + outbox +;; projections fold through followers. +(epoch 22) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), S = projection:query(followers), {follower_graph:followers(alice, S), follower_graph:following(bob, S)} =:= {[bob], [alice]}\") :name)") + +;; auto_accept on: pendings cleared after the Accept fold +(epoch 23) +(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), S = projection:query(followers), {follower_graph:pending_inbound(alice, S), follower_graph:pending_outbound(bob, S)} =:= {[], []}\") :name)") + +;; auto_accept off (default): no outbox publish; outbox tip stays 0 +(epoch 24) +(eval "(erlang-eval-ast \"${SETUP} CfgOff = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, CfgOff), nx_kernel:log_tip_for(alice)\")") + +;; auto_accept off: pending_inbound still gets populated (Step 6b path) +;; but no Accept fired, so alice.followers stays empty. +(epoch 25) +(eval "(get (erlang-eval-ast \"${SETUP} CfgOff = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, CfgOff), S = projection:query(followers), {follower_graph:pending_inbound(alice, S), follower_graph:followers(alice, S)} =:= {[bob], []}\") :name)") + +;; Non-Follow activity (Create{Note}) with auto_accept on: outbox stays empty +(epoch 26) +(eval "(erlang-eval-ast \"${SETUP} NoteEnv = outbox:construct(create, bob, 2, [{type, note}, {content, hi}]), SignedNote = outbox:sign(NoteEnv, BKS), NoteBody = term_codec:encode(SignedNote), Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, NoteBody}], http_server:route(Req, Cfg), nx_kernel:log_tip_for(alice)\")") + +;; Bad-sig Follow ingestion with auto_accept on: no Accept publish (short-circuit) +(epoch 27) +(eval "(erlang-eval-ast \"${SETUP} EvilK = <<9,9,9,9>>, EvilAS = [{public_keys,[[{id,k1},{created,0},{value,EvilK}]]}], EvilCfg = [{peer_as, [{bob, EvilAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}, {auto_accept_follows, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, EvilCfg), nx_kernel:log_tip_for(alice)\")") +EPOCHS + +OUTPUT=$(timeout 900 "$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 11 "http_server loaded" "http_server" +check 20 "auto_accept on: outbox tip = 1" "1" +check 21 "outbox entry is an Accept" "true" +check 22 "graph converges to full Follow" "true" +check 23 "pendings cleared after Accept" "true" +check 24 "auto_accept off: outbox tip = 0" "0" +check 25 "auto_accept off: pending only" "true" +check 26 "non-Follow ingestion: no Accept" "0" +check 27 "bad-sig short-circuits Accept" "0" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/auto_accept.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 a5431321..6063583c 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -450,13 +450,21 @@ tracks the state. `Undo{Follow}` reverses it. no-op path, bad-sig short-circuit (projection stays clean), multi-peer accumulation, end-to-end Follow+Accept projection convergence (Accept fed in via projection:async_fold for v2). -- [ ] **6c** — Auto-Accept publish. On Follow ingestion, the - receiving kernel constructs an `Accept{actor: target, object: - Follow}` envelope, signs it with the target's key, and - publishes via `nx_kernel:publish_to/2`. Per design §13.2 the - policy is open-world (auto-accept every Follow); manual - moderation (held in a pending list, accepted via /admin/) is - v3. +- [x] **6c** — Auto-Accept publish. New `maybe_auto_accept/3` in + `http_server.erl` fires after a successful inbox ingestion if + Cfg carries `{auto_accept_follows, true}` AND the activity's + `:type` is `follow`. The handler constructs an + `Accept{actor: target, object: OriginalFollow}` request and + routes it through `nx_kernel:publish_to/2`, which goes through + the full outbox pipeline (envelope construct + HMAC sign + log + append + outbox projection broadcast). When the target's + outbox `:projections` list includes the same follower_graph + projection the inbox uses, the Accept fold-converges the + bilateral relationship — `alice.followers = [bob]` and + `bob.following = [alice]` — without any test scaffolding. + Default is off; manual-moderation deployments leave the flag + unset. Bad-sig / non-Follow ingestion short-circuits before + the Accept attempt. 9/9 in `auto_accept.sh`. **Acceptance:** `bash next/tests/follow_lifecycle.sh` passes 14+ cases. @@ -829,6 +837,22 @@ proceed. Newest first. +- **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: + if Cfg has `{auto_accept_follows, true}` and the activity is a + `Follow`, construct `[{type, accept}, {object, OriginalFollow}]` + and route through `nx_kernel:publish_to/2`. The publish goes + through the full outbox pipeline (construct + sign + log + + projection broadcast), so when the target's outbox `:projections` + share the same follower_graph projection that inbox broadcasts + into, the bilateral relationship fold-converges automatically + (`alice.followers = [bob]`, `bob.following = [alice]`, both + pending lists clear). Default off; bad-sig / non-Follow + ingestion short-circuits before the Accept attempt. 9/9 in + `auto_accept.sh`. Conformance 761/761. Step 6 fully closed + (6a + 6b + 6c). + - **2026-06-06** — Step 6b: wire follower_graph fold to the inbox handler. New `broadcast_to_inbox_projections/2` in `http_server.erl` casts every successfully-ingested activity