fed-sx-m2: Step 6a — follower_graph projection + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s

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).
This commit is contained in:
2026-06-06 20:47:01 +00:00
parent 6231a82be0
commit e890380a1a
3 changed files with 433 additions and 17 deletions

View File

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

159
next/tests/follower_graph.sh Executable file
View File

@@ -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" <<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)")
;; new/0 -> []
(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="<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 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 ]

View File

@@ -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/<id>/inbox real ingestion.
`route/2` now special-cases POST `/actors/<id>/inbox` next to POST
`/activity` so the body + full Cfg reach the new