From e890380a1a1b7212ffdf39b8835d6b06f53b686a Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 20:47:01 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=206a=20=E2=80=94=20follower?= =?UTF-8?q?=5Fgraph=20projection=20+=2018=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New next/kernel/follower_graph.erl is the Erlang-fun stand-in for the genesis follower-graph.sx projection body, mirroring the shape of actor_state.erl and define_registry.erl. State shape (substrate has no maps, so a proplist): [{ActorId, [{following, [PeerId, ...]}, {followers, [PeerId, ...]}, {pending_outbound, [PeerId, ...]}, {pending_inbound, [PeerId, ...]}]}, ...] Fold rules per design §13.2: Follow{actor: A, object: B} add B to A.pending_outbound add A to B.pending_inbound Accept{actor: B, object: Follow{A->B}} A moves from B.pending_inbound -> B.followers B moves from A.pending_outbound -> A.following Reject{actor: B, object: Follow{A->B}} clear A from B.pending_inbound, B from A.pending_outbound Undo{actor: A, object: Follow{A->B}} drop A<->B from every list on either side only the Follow's original actor may Undo it Edge cases handled: - self-follow (alice -> alice) is a no-op - duplicate Follow is idempotent (list sets) - Accept/Reject/Undo whose :object isn't a Follow proplist passes through - Undo by the wrong actor (carol Undoing Follow{alice->bob}) is a no-op Public API: new/0, lookup/2, actors/1 following/2, followers/2, pending_outbound/2, pending_inbound/2 is_following/3, has_follower/3, is_pending_outbound/3, is_pending_inbound/3 fold/2, fold_fn/0 fold_fn/0 returns the standard 2-arity Erlang fun for projection:start_link/3 (same plug shape as actor_state and define_registry). Local find_keyed/set_keyed/contains/remove_member helpers — no lists:keyfind/keymember/member in this substrate (same gap as Step 1a/2b/5a/5c). 18/18 in next/tests/follower_graph.sh covering all four verbs, predicates, edge cases (self-follow, duplicate Follow, untyped activity, non-Follow :object, wrong-actor Undo). Step 6b wires this into the inbox handler so a peer Follow lands, fires auto-Accept publish (open-world policy per §13.2; manual moderation deferred to v3). Conformance 761/761. 130/130 across 9 Step-6-adjacent suites (inbox, inbox_bucket, inbox_pipeline, inbox_peer_resolution, actor_state_pure, define_registry_pure, projection_pure, nx_kernel_multi, smoke_app_pure). --- next/kernel/follower_graph.erl | 237 +++++++++++++++++++++++++++++++++ next/tests/follower_graph.sh | 159 ++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 54 +++++--- 3 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 next/kernel/follower_graph.erl create mode 100755 next/tests/follower_graph.sh diff --git a/next/kernel/follower_graph.erl b/next/kernel/follower_graph.erl new file mode 100644 index 00000000..ad3f7805 --- /dev/null +++ b/next/kernel/follower_graph.erl @@ -0,0 +1,237 @@ +-module(follower_graph). +-export([fold/2, fold_fn/0, new/0, lookup/2, actors/1, + following/2, followers/2, + pending_outbound/2, pending_inbound/2, + is_following/3, has_follower/3, + is_pending_outbound/3, is_pending_inbound/3]). + +%% Follower-graph projection — Erlang-fun stand-in for the genesis +%% `follower-graph.sx` body. Tracks per-actor follow relationships +%% per design §13.2: +%% +%% Follow {actor: A, object: B} A asks to follow B +%% Accept {actor: B, object: F} B accepts A's Follow F (= F.actor → F.object) +%% Reject {actor: B, object: F} B rejects A's Follow F +%% Undo {actor: A, object: F} A retracts F or unfollows +%% +%% Where F = Follow{A→B} is embedded as the activity's :object +%% proplist for Accept / Reject / Undo. +%% +%% State shape: +%% [{ActorId, ActorEntry}, ...] +%% +%% ActorEntry = [{following, [PeerId, ...]}, +%% {followers, [PeerId, ...]}, +%% {pending_outbound, [PeerId, ...]}, %% I asked, no answer yet +%% {pending_inbound, [PeerId, ...]}] %% asked me, I haven't answered +%% +%% Sets keep insertion order; duplicates aren't added. lists:keyfind/ +%% keymember aren't in this substrate, so local find_keyed/has_keyed/ +%% set_keyed helpers (same convention as actor_state, define_registry, +%% nx_kernel). + +%% ── Public API ────────────────────────────────────────────────── + +new() -> []. + +actors(State) -> [Id || {Id, _Entry} <- State]. + +lookup(ActorId, State) -> + case find_keyed(ActorId, State) of + {ok, Entry} -> {ok, Entry}; + _ -> not_found + end. + +following(ActorId, State) -> entry_field(ActorId, following, State). +followers(ActorId, State) -> entry_field(ActorId, followers, State). +pending_outbound(ActorId, State) -> entry_field(ActorId, pending_outbound, State). +pending_inbound(ActorId, State) -> entry_field(ActorId, pending_inbound, State). + +is_following(ActorId, PeerId, State) -> + contains(PeerId, following(ActorId, State)). + +has_follower(ActorId, PeerId, State) -> + contains(PeerId, followers(ActorId, State)). + +is_pending_outbound(ActorId, PeerId, State) -> + contains(PeerId, pending_outbound(ActorId, State)). + +is_pending_inbound(ActorId, PeerId, State) -> + contains(PeerId, pending_inbound(ActorId, State)). + +%% ── Fold dispatch ─────────────────────────────────────────────── + +fold(Activity, State) -> + case envelope:get_field(type, Activity) of + {ok, follow} -> fold_follow(Activity, State); + {ok, accept} -> fold_accept(Activity, State); + {ok, reject} -> fold_reject(Activity, State); + {ok, undo} -> fold_undo(Activity, State); + _ -> State + end. + +fold_fn() -> + fun (Activity, State) -> fold(Activity, State) end. + +%% Follow {actor: A, object: B}: +%% add B to A's pending_outbound +%% add A to B's pending_inbound +fold_follow(Activity, State) -> + case follow_actor_object(Activity) of + {ok, A, B} when A =/= B -> + S1 = add_to_field(A, pending_outbound, B, State), + add_to_field(B, pending_inbound, A, S1); + _ -> State + end. + +%% Accept {actor: B, object: Follow{A→B}}: +%% move A from B's pending_inbound to B's followers +%% move B from A's pending_outbound to A's following +fold_accept(Activity, State) -> + case nested_follow_actor_object(Activity) of + {ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B -> + S1 = move_field(B, pending_inbound, followers, A, State), + move_field(A, pending_outbound, following, B, S1); + _ -> State + end. + +%% Reject {actor: B, object: Follow{A→B}}: +%% drop A from B's pending_inbound +%% drop B from A's pending_outbound +fold_reject(Activity, State) -> + case nested_follow_actor_object(Activity) of + {ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B -> + S1 = drop_from_field(B, pending_inbound, A, State), + drop_from_field(A, pending_outbound, B, S1); + _ -> State + end. + +%% Undo {actor: X, object: Follow{A→B}}: +%% Only the original Follow's actor (A) can Undo it. +%% Drops A↔B from every list on either side. +fold_undo(Activity, State) -> + case nested_follow_actor_object(Activity) of + {ok, X, OrigA, OrigA, OrigB} when X =:= OrigA, OrigA =/= OrigB -> + S1 = drop_from_field(OrigA, following, OrigB, State), + S2 = drop_from_field(OrigA, pending_outbound, OrigB, S1), + S3 = drop_from_field(OrigB, followers, OrigA, S2), + drop_from_field(OrigB, pending_inbound, OrigA, S3); + _ -> State + end. + +%% ── Extraction helpers ───────────────────────────────────────── + +follow_actor_object(Activity) -> + case envelope:get_field(actor, Activity) of + {ok, A} -> + case envelope:get_field(object, Activity) of + {ok, B} when is_atom(B) -> {ok, A, B}; + _ -> not_follow + end; + _ -> not_follow + end. + +%% nested_follow_actor_object/1 — pull (Actor, FollowActor, FollowObject) +%% out of an envelope whose :object is itself a Follow proplist. +%% Returns {ok, OuterActor, InferredPeer, InnerActor, InnerObject}. +nested_follow_actor_object(Activity) -> + case envelope:get_field(actor, Activity) of + {ok, Outer} -> + case envelope:get_field(object, Activity) of + {ok, Inner} when is_list(Inner) -> + case nested_is_follow(Inner) of + true -> + case {envelope:get_field(actor, Inner), + envelope:get_field(object, Inner)} of + {{ok, IA}, {ok, IO}} when is_atom(IO) -> + {ok, Outer, peer_from_inner(Outer, IA, IO), IA, IO}; + _ -> not_a_follow_wrapper + end; + false -> not_a_follow_wrapper + end; + _ -> not_a_follow_wrapper + end; + _ -> not_a_follow_wrapper + end. + +nested_is_follow(Inner) -> + case envelope:get_field(type, Inner) of + {ok, follow} -> true; + _ -> false + end. + +%% peer_from_inner — for an Accept/Reject by B of Follow{A→B}, +%% Outer = B; the "peer" we move state for is A. For an Undo by A, +%% Outer = A; the peer is B. Picking the inner actor/object that +%% isn't Outer gives us the right pair-mate. +peer_from_inner(Outer, IA, _IO) when Outer =:= IA -> IA; +peer_from_inner(_Outer, IA, _IO) -> IA. + +%% ── Entry / field accessors ──────────────────────────────────── + +entry_field(ActorId, Field, State) -> + case find_keyed(ActorId, State) of + {ok, Entry} -> + case find_keyed(Field, Entry) of + {ok, Val} -> Val; + _ -> [] + end; + _ -> [] + end. + +empty_entry() -> + [{following, []}, + {followers, []}, + {pending_outbound, []}, + {pending_inbound, []}]. + +ensure_entry(ActorId, State) -> + case find_keyed(ActorId, State) of + {ok, _} -> State; + _ -> State ++ [{ActorId, empty_entry()}] + end. + +add_to_field(ActorId, Field, PeerId, State) -> + S1 = ensure_entry(ActorId, State), + {ok, Entry} = find_keyed(ActorId, S1), + Current = entry_field(ActorId, Field, S1), + NewList = case contains(PeerId, Current) of + true -> Current; + false -> Current ++ [PeerId] + end, + NewEntry = set_keyed(Field, NewList, Entry), + set_keyed(ActorId, NewEntry, S1). + +drop_from_field(ActorId, Field, PeerId, State) -> + case find_keyed(ActorId, State) of + {ok, Entry} -> + Current = entry_field(ActorId, Field, State), + NewList = remove_member(PeerId, Current), + NewEntry = set_keyed(Field, NewList, Entry), + set_keyed(ActorId, NewEntry, State); + _ -> State + end. + +move_field(ActorId, FromField, ToField, PeerId, State) -> + S1 = drop_from_field(ActorId, FromField, PeerId, State), + add_to_field(ActorId, ToField, PeerId, S1). + +%% ── List helpers ─────────────────────────────────────────────── + +contains(_, []) -> false; +contains(X, [X | _]) -> true; +contains(X, [_ | Rest]) -> contains(X, Rest). + +remove_member(_, []) -> []; +remove_member(X, [X | Rest]) -> remove_member(X, Rest); +remove_member(X, [Y | Rest]) -> [Y | remove_member(X, Rest)]. + +%% ── Keyed-list helpers ───────────────────────────────────────── + +find_keyed(_, []) -> {error, not_found}; +find_keyed(K, [{K, V} | _]) -> {ok, V}; +find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). + +set_keyed(K, V, []) -> [{K, V}]; +set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest]; +set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)]. diff --git a/next/tests/follower_graph.sh b/next/tests/follower_graph.sh new file mode 100755 index 00000000..29d66e7b --- /dev/null +++ b/next/tests/follower_graph.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# next/tests/follower_graph.sh — m2 Step 6a test. +# +# Pure projection fold over Follow / Accept / Reject / Undo +# activities per design §13.2. State tracks per-actor +# {following, followers, pending_outbound, pending_inbound} lists. + +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 + +# F(A→B) is the embedded Follow object Accept / Reject / Undo wrap. +SETUP='F = [{type, follow}, {actor, alice}, {object, bob}], Follow = [{actor, alice}, {type, follow}, {object, bob}], Accept = [{actor, bob}, {type, accept}, {object, F}], Reject = [{actor, bob}, {type, reject}, {object, F}], Undo = [{actor, alice}, {type, undo}, {object, F}],' + +cat > "$TMPFILE" < [] +(epoch 10) +(eval "(get (erlang-eval-ast \"follower_graph:new() =:= []\") :name)") + +;; Follow alice->bob: alice has pending_outbound = [bob]; bob pending_inbound = [alice] +(epoch 11) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:pending_outbound(alice, S) =:= [bob] andalso follower_graph:pending_inbound(bob, S) =:= [alice]\") :name)") + +;; After Follow alone, neither party shows the other as following/follower +(epoch 12) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:following(alice, S) =:= [] andalso follower_graph:followers(bob, S) =:= []\") :name)") + +;; Accept: alice moves into bob's followers; bob moves into alice's following +(epoch 13) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), follower_graph:followers(bob, S1) =:= [alice] andalso follower_graph:following(alice, S1) =:= [bob]\") :name)") + +;; Accept: both pending lists cleared on each side +(epoch 14) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= []\") :name)") + +;; Reject: pending lists clear without populating following/followers +(epoch 15) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Reject, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= [] andalso follower_graph:following(alice, S1) =:= [] andalso follower_graph:followers(bob, S1) =:= []\") :name)") + +;; Undo by alice after accept: drops both following and followers +(epoch 16) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), S2 = follower_graph:fold(Undo, S1), follower_graph:following(alice, S2) =:= [] andalso follower_graph:followers(bob, S2) =:= []\") :name)") + +;; Undo before accept: pending lists clear +(epoch 17) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Undo, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= []\") :name)") + +;; Self-follow ignored (alice follows alice no-ops) +(epoch 18) +(eval "(get (erlang-eval-ast \"SelfFollow = [{actor, alice}, {type, follow}, {object, alice}], S = follower_graph:fold(SelfFollow, follower_graph:new()), follower_graph:new() =:= S\") :name)") + +;; Two distinct follows: alice->bob, carol->bob produce two pending_inbound entries on bob +(epoch 19) +(eval "(get (erlang-eval-ast \"${SETUP} F2 = [{actor, carol}, {type, follow}, {object, bob}], S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(F2, S), follower_graph:pending_inbound(bob, S1) =:= [alice, carol]\") :name)") + +;; Duplicate Follow is idempotent (no double-add) +(epoch 20) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Follow, S), follower_graph:pending_outbound(alice, S1) =:= [bob]\") :name)") + +;; Predicates: is_following / has_follower / pendings after accept +(epoch 21) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), {follower_graph:is_following(alice, bob, S), follower_graph:has_follower(bob, alice, S), follower_graph:is_pending_outbound(alice, bob, S), follower_graph:is_pending_inbound(bob, alice, S)} =:= {true, true, false, false}\") :name)") + +;; actors/1 lists every actor seen (alice + bob after one Follow, +;; in insertion order: alice's bucket added first, then bob's) +(epoch 22) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:actors(S) =:= [alice, bob]\") :name)") + +;; fold_fn/0 is a 2-arity Erlang fun (plugs into projection:start_link) +(epoch 23) +(eval "(get (erlang-eval-ast \"is_function(follower_graph:fold_fn(), 2)\") :name)") + +;; Activity sans :type passes through +(epoch 24) +(eval "(get (erlang-eval-ast \"Garbage = [{actor, alice}], follower_graph:fold(Garbage, follower_graph:new()) =:= []\") :name)") + +;; Accept whose embedded :object isn't a Follow passes through +(epoch 25) +(eval "(get (erlang-eval-ast \"BadAccept = [{actor, bob}, {type, accept}, {object, [{type, note}, {actor, alice}, {object, bob}]}], follower_graph:fold(BadAccept, follower_graph:new()) =:= []\") :name)") + +;; Undo by the wrong actor (carol trying to undo F where A=alice) is a no-op +(epoch 26) +(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), BadUndo = [{actor, carol}, {type, undo}, {object, F}], S1 = follower_graph:fold(BadUndo, S), follower_graph:following(alice, S1) =:= [bob]\") :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 3 "follower_graph module loaded" "follower_graph" +check 10 "new/0 -> []" "true" +check 11 "Follow sets pendings each side" "true" +check 12 "Follow alone: no following/follower" "true" +check 13 "Accept promotes to following/followers" "true" +check 14 "Accept clears pendings" "true" +check 15 "Reject clears without promote" "true" +check 16 "Undo after accept drops rel" "true" +check 17 "Undo before accept clears pending" "true" +check 18 "self-follow is a no-op" "true" +check 19 "two follows -> two pending_inbound" "true" +check 20 "duplicate Follow idempotent" "true" +check 21 "predicates after accept" "true" +check 22 "actors/1 lists every seen" "true" +check 23 "fold_fn/0 is fun/2" "true" +check 24 "untyped activity passes through" "true" +check 25 "Accept of non-Follow passes through" "true" +check 26 "Undo by wrong actor no-op" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/follower_graph.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 c41062de..62d3da5c 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -419,23 +419,28 @@ tracks the state. `Undo{Follow}` reverses it. **Deliverables:** -- New activity-types (runtime via DefineActivity, ideally): - Follow, Accept, Reject, Undo. -- Follower-graph projection (Erlang-fun stand-in): tracks - `{ActorId => #{following => [PeerId], followers => [PeerId], - pending_outbound => [PeerId], pending_inbound => [PeerId]}}`. -- Accept-handling fold logic: when A receives `Accept{Follow A→B}`, - move B from `pending_outbound` to `following`. -- Reciprocal: when B receives `Follow A→B`, automatically queue an - outbound `Accept` (auto-accept policy; manual moderation v3). - -**Tests:** - -- Follow → 202; sender's pending_outbound includes target. -- Auto-Accept on receiving Follow; both sides' graphs update. -- Reject leaves no following relationship. -- Undo{Follow} removes the following. -- Self-follow rejected. +- [x] **6a** — `follower_graph.erl` Erlang-fun stand-in for the + genesis `follower-graph.sx` projection body. State shape is a + property-list keyed by ActorId (maps `#{}` not in substrate), + each entry carries `{following, followers, pending_outbound, + pending_inbound}` lists. Fold rules: + - `Follow{actor: A, object: B}` — A → pending_outbound(B); + B → pending_inbound(A). + - `Accept{actor: B, object: F=Follow{A→B}}` — A → following(B) + on A's bucket; B → followers(A) on B's bucket; pendings cleared. + - `Reject{actor: B, object: F}` — pendings cleared, no promote. + - `Undo{actor: A, object: F}` — drops A↔B from every list; only + F's original actor can Undo (carol can't Undo F{A→B}). + Self-follows are no-ops; duplicate Follows are idempotent; + Accept/Reject/Undo of non-Follow `:object`s pass through. + 18 cases in `follower_graph.sh`. The `fold_fn/0` 2-arity fun + plugs into `projection:start_link/3` exactly like + `define_registry:fold_fn/0` and `actor_state:fold_fn/0`. +- [ ] **6b** — Wire follower-graph fold to the inbox handler so a + peer Follow lands, fires auto-Accept publish (open-world policy + per §13.2; manual moderation deferred to v3). Acceptance test + in `follow_lifecycle.sh` covering the end-to-end + Follow → inbox → auto-Accept → projection-state-converges flow. **Acceptance:** `bash next/tests/follow_lifecycle.sh` passes 14+ cases. @@ -808,6 +813,21 @@ proceed. Newest first. +- **2026-06-06** — Step 6a: follower-graph projection + (`follower_graph.erl`). Pure-functional fold over Follow / + Accept / Reject / Undo activities per design §13.2. State is a + proplist keyed by ActorId carrying `{following, followers, + pending_outbound, pending_inbound}` lists. Follow pushes onto + pendings; Accept moves both sides from pendings into the + permanent lists; Reject just clears pendings; Undo drops the + pair everywhere (and only the Follow's original actor can Undo). + Self-follow is a no-op; duplicate Follow is idempotent; + Accept/Reject/Undo of a non-Follow `:object` passes through. + `fold_fn/0` is the standard 2-arity fun for + `projection:start_link/3` (same shape as `actor_state` and + `define_registry`). 18/18 in `follower_graph.sh`. Conformance + 761/761. + - **2026-06-06** — Step 5d: POST /actors//inbox real ingestion. `route/2` now special-cases POST `/actors//inbox` next to POST `/activity` so the body + full Cfg reach the new