fed-sx-m2: Step 6c — auto-Accept on Follow ingestion + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s

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).
This commit is contained in:
2026-06-06 22:46:52 +00:00
parent 1d83120918
commit ee8a396ccd
3 changed files with 193 additions and 7 deletions

View File

@@ -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.

136
next/tests/auto_accept.sh Executable file
View File

@@ -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" <<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 "(er-load-gen-server!)")
(epoch 3)
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
(epoch 4)
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
(epoch 5)
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
(epoch 6)
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
(epoch 7)
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
(epoch 8)
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
(epoch 9)
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
(epoch 10)
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
(epoch 11)
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
;; auto_accept on: Follow ingestion advances alice's outbox tip (Accept published)
(epoch 20)
(eval "(erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {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="<no output for epoch $epoch>"
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 ]

View File

@@ -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