From 0b8772ec697bbcec2b3d90afd83de5ce4151ea14 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 13:47:00 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=204a=20=E2=80=94=20per-actor?= =?UTF-8?q?=20HTTP=20sub-paths=20+=2017=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design §16.1 each actor has /outbox /inbox /followers /following sub-paths. New split_first_slash/1 helper lets the GET /actors/... dispatch arm fan out on the sub-segment: GET /actors/ actor doc (M1 — unchanged) GET /actors//outbox outbox stub (4a) GET /actors//inbox inbox stub (4a) GET /actors//followers follower stub (4a) GET /actors//following following stub (4a) POST /actors//inbox 202 Accepted stub (4a; Step 5 real) Four new content-negotiated response functions mirror the existing actor_doc_response_for/2 shape (text / json / activity_json / sx variants): actor_outbox_response_for/2 actor_inbox_get_response_for/2 actor_followers_response_for/2 actor_following_response_for/2 POST returns 202 via new accepted_response/1 + actor_inbox_post_response/0. Unknown sub-paths under /actors// return 404. Bare /actors/ preserves the M1 actor-doc arm so http_route + http_post_format regression suites stay green. 4b-4e (token map, route/3 kernel access, per-actor outbox listing from log entries, real inbox pipeline) layer on top of this dispatch in subsequent iterations. 17/17 in next/tests/http_multi_actor.sh covering: - split_first_slash sanity (no slash / id+sub / trailing slash) - all four GET sub-paths return 200 with stub bodies - POST inbox returns 202 + 'accepted' - unknown sub-paths return 404 (GET and POST) - empty /actors/ returns 404 - body carries the actor id - content negotiation: outbox JSON, inbox SX, followers JSON Conformance 761/761. 120/120 across 10 Step-4-adjacent suites (http_route, http_publish, http_post_format, http_marshal, http_publish_fold, http_listen_bif, http_server_start, nx_kernel_multi, actor_state_pure, bootstrap_start). --- next/kernel/http_server.erl | 173 ++++++++++++++++++++++++++++++++- next/tests/http_multi_actor.sh | 151 ++++++++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 60 +++++++++--- 3 files changed, 365 insertions(+), 19 deletions(-) create mode 100755 next/tests/http_multi_actor.sh diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index d42a2225..a629aac1 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -16,7 +16,11 @@ content_type_for/1, ok_response/2, cid_response_for/2, post_activity_response_for/1, actor_doc_response_for/2, artifact_response_for/2, - projection_response_for/2, projections_list_response_for/1]). + projection_response_for/2, projections_list_response_for/1, + actor_outbox_response_for/2, actor_inbox_get_response_for/2, + actor_followers_response_for/2, actor_following_response_for/2, + actor_inbox_post_response/0, accepted_response/1, + split_first_slash/1]). %% HTTP request router per design §16.1. %% @@ -94,11 +98,11 @@ dispatch(<<71, 69, 84>>, %% prefix clause because the bare path has no trailing slash. dispatch(<<71, 69, 84>>, <<47,112,114,111,106,101,99,116,105,111,110,115>>, F) -> projections_list_response_for(F); -%% GET /actors/{id} or /artifacts/{cid} or /projections/{name} +%% GET /actors/{id}[/sub] or /artifacts/{cid} or /projections/{name} dispatch(<<71, 69, 84>>, Path, F) -> case match_prefix(actors_prefix(), Path) of - {ok, Id} when byte_size(Id) > 0 -> - actor_doc_response_for(Id, F); + {ok, Rest} when byte_size(Rest) > 0 -> + actor_get(Rest, F); _ -> case match_prefix(artifacts_prefix(), Path) of {ok, Cid} when byte_size(Cid) > 0 -> @@ -112,9 +116,71 @@ dispatch(<<71, 69, 84>>, Path, F) -> end end end; +%% POST /actors/{id}/inbox — peer-side delivery (Step 4a returns +%% 202 Accepted stub; Step 5 lands the real ingestion pipeline). +dispatch(<<80, 79, 83, 84>>, Path, _F) -> + case match_prefix(actors_prefix(), Path) of + {ok, Rest} when byte_size(Rest) > 0 -> + actor_post(Rest); + _ -> + not_found_response() + end; dispatch(_, _, _) -> not_found_response(). +%% actor_get/2 — Rest is the part after "/actors/". If it has no +%% inner slash, it's the bare actor doc. Otherwise dispatch on the +%% sub-segment. + +actor_get(Rest, F) -> + case split_first_slash(Rest) of + {Id, <<>>} -> actor_doc_response_for(Id, F); + {Id, Sub} -> actor_subresource_get(Id, Sub, F); + Id -> actor_doc_response_for(Id, F) + end. + +%% 111 117 116 98 111 120 = "outbox" +actor_subresource_get(Id, <<111,117,116,98,111,120>>, F) -> + actor_outbox_response_for(Id, F); +%% 105 110 98 111 120 = "inbox" +actor_subresource_get(Id, <<105,110,98,111,120>>, F) -> + actor_inbox_get_response_for(Id, F); +%% 102 111 108 108 111 119 101 114 115 = "followers" +actor_subresource_get(Id, <<102,111,108,108,111,119,101,114,115>>, F) -> + actor_followers_response_for(Id, F); +%% 102 111 108 108 111 119 105 110 103 = "following" +actor_subresource_get(Id, <<102,111,108,108,111,119,105,110,103>>, F) -> + actor_following_response_for(Id, F); +actor_subresource_get(_, _, _) -> + not_found_response(). + +actor_post(Rest) -> + case split_first_slash(Rest) of + {_Id, <<105,110,98,111,120>>} -> + actor_inbox_post_response(); + _ -> + not_found_response() + end. + +%% split_first_slash/1 — split a binary on the first slash. Returns +%% {Before, After} where After omits the slash itself. If no slash +%% is present, returns just Before. 47 = "/". +%% +%% <<"alice">> -> <<"alice">> +%% <<"alice/">> -> {<<"alice">>, <<>>} +%% <<"alice/inbox">> -> {<<"alice">>, <<"inbox">>} +%% <<"alice/inbox/x">> -> {<<"alice">>, <<"inbox/x">>} + +split_first_slash(Bin) -> + split_first_slash(Bin, <<>>). + +split_first_slash(<<>>, Acc) -> + Acc; +split_first_slash(<<47, Rest/binary>>, Acc) -> + {Acc, Rest}; +split_first_slash(<>, Acc) -> + split_first_slash(Rest, <>). + %% "fed-sx kernel m1\n" — 17 bytes, hand-spelled. %% f e d - s x _ k e r n e l _ m 1 \n welcome_body() -> @@ -538,6 +604,105 @@ actor_doc_response_for(Id, cbor) -> actor_doc_response_for(Id, _) -> actor_doc_response(Id). +%% ── Step 4a: per-actor sub-resource stubs ────────────────────── +%% Per design §16.1 each actor has /outbox /inbox /followers +%% /following routes. v1 returns text-stub bodies so route resolution +%% can be tested end-to-end; real serialisation of per-actor outbox +%% listings (Step 4d) + follower-graph projection bodies (Step 6+) +%% layer on top of these dispatch arms. + +%% "outbox: " — 8 bytes +actor_outbox_response_for(Id, text) -> + Pre = <<111,117,116,98,111,120,58,32>>, + ok_response(<
>);
+actor_outbox_response_for(Id, json) ->
+    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,  % '{"outbox":"'
+    Suf = <<34,125,10>>,
+    ok_response(<
>, json);
+actor_outbox_response_for(Id, activity_json) ->
+    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, activity_json);
+actor_outbox_response_for(Id, sx) ->
+    Pre = <<40,111,117,116,98,111,120,32,34>>,         % '(outbox "'
+    Suf = <<34,41,10>>,
+    ok_response(<
>, sx);
+actor_outbox_response_for(Id, _) ->
+    Pre = <<111,117,116,98,111,120,58,32>>,
+    ok_response(<
>).
+
+%% "inbox: " — 7 bytes
+actor_inbox_get_response_for(Id, text) ->
+    Pre = <<105,110,98,111,120,58,32>>,
+    ok_response(<
>);
+actor_inbox_get_response_for(Id, json) ->
+    Pre = <<123,34,105,110,98,111,120,34,58,34>>,      % '{"inbox":"'
+    Suf = <<34,125,10>>,
+    ok_response(<
>, json);
+actor_inbox_get_response_for(Id, activity_json) ->
+    Pre = <<123,34,105,110,98,111,120,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, activity_json);
+actor_inbox_get_response_for(Id, sx) ->
+    Pre = <<40,105,110,98,111,120,32,34>>,             % '(inbox "'
+    Suf = <<34,41,10>>,
+    ok_response(<
>, sx);
+actor_inbox_get_response_for(Id, _) ->
+    Pre = <<105,110,98,111,120,58,32>>,
+    ok_response(<
>).
+
+%% "followers: " — 11 bytes
+actor_followers_response_for(Id, text) ->
+    Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
+    ok_response(<
>);
+actor_followers_response_for(Id, json) ->
+    Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, json);
+actor_followers_response_for(Id, activity_json) ->
+    Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, activity_json);
+actor_followers_response_for(Id, sx) ->
+    Pre = <<40,102,111,108,108,111,119,101,114,115,32,34>>,
+    Suf = <<34,41,10>>,
+    ok_response(<
>, sx);
+actor_followers_response_for(Id, _) ->
+    Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
+    ok_response(<
>).
+
+%% "following: " — 11 bytes
+actor_following_response_for(Id, text) ->
+    Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
+    ok_response(<
>);
+actor_following_response_for(Id, json) ->
+    Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, json);
+actor_following_response_for(Id, activity_json) ->
+    Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
+    Suf = <<34,125,10>>,
+    ok_response(<
>, activity_json);
+actor_following_response_for(Id, sx) ->
+    Pre = <<40,102,111,108,108,111,119,105,110,103,32,34>>,
+    Suf = <<34,41,10>>,
+    ok_response(<
>, sx);
+actor_following_response_for(Id, _) ->
+    Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
+    ok_response(<
>).
+
+%% POST /actors//inbox stub — 202 Accepted with body "accepted\n".
+%% Real ingestion pipeline (sig verify + envelope:get_field + log
+%% append on the receiving actor's inbox bucket) lands in Step 5.
+
+actor_inbox_post_response() ->
+    %% "accepted\n" — 9 bytes
+    Body = <<97,99,99,101,112,116,101,100,10>>,
+    accepted_response(Body).
+
+accepted_response(Body) ->
+    [{status, 202}, {headers, []}, {body, Body}].
+
 %% artifact_response — text body `artifact: \n`.
 
 artifact_response_for(Cid, text) ->
diff --git a/next/tests/http_multi_actor.sh b/next/tests/http_multi_actor.sh
new file mode 100755
index 00000000..bc8f7de5
--- /dev/null
+++ b/next/tests/http_multi_actor.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# next/tests/http_multi_actor.sh — m2 Step 4 tests (4a: per-actor
+# URL sub-paths).
+#
+# Per design §16.1 each actor has:
+#   GET  /actors/            actor doc (M1)
+#   GET  /actors//outbox     outbox listing (4a: stub)
+#   GET  /actors//inbox      inbox listing (4a: stub)
+#   GET  /actors//followers  follower list (4a: stub)
+#   GET  /actors//following  following list (4a: stub)
+#   POST /actors//inbox      peer delivery (4a: 202 stub; Step 5 real)
+#
+# 4b-4e wire the routes to per-actor kernel state + token map.
+
+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
+
+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/http_server.erl\")) :name)")
+
+;; split_first_slash sanity
+(epoch 10)
+(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101>>) =:= <<97,108,105,99,101>>\") :name)")
+(epoch 11)
+(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101,47,105,110,98,111,120>>) =:= {<<97,108,105,99,101>>, <<105,110,98,111,120>>}\") :name)")
+(epoch 12)
+(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101,47>>) =:= {<<97,108,105,99,101>>, <<>>}\") :name)")
+
+;; GET /actors/alice returns actor doc (regression check — M1 path)
+(epoch 20)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<97,99,116,111,114,58>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; GET /actors/alice/outbox returns outbox stub
+(epoch 21)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<111,117,116,98,111,120,58>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; GET /actors/alice/inbox returns inbox stub
+(epoch 22)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<105,110,98,111,120,58>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; GET /actors/alice/followers returns followers stub
+(epoch 23)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,101,114,115>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<102,111,108,108,111,119,101,114,115,58>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; GET /actors/alice/following returns following stub
+(epoch 24)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,105,110,103>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<102,111,108,108,111,119,105,110,103,58>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; POST /actors/alice/inbox returns 202 with "accepted"
+(epoch 25)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<80,79,83,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 202}, _, {body, B}] -> http_server:match_prefix(<<97,99,99,101,112,116,101,100>>, B) =/= nomatch; _ -> false end\") :name)")
+
+;; GET /actors/alice/unknown returns 404
+(epoch 26)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,117,110,107,110,111,119,110>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
+
+;; POST /actors/alice/unknown returns 404
+(epoch 27)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<80,79,83,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,117,110,107,110,111,119,110>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
+
+;; GET /actors/ (no id) returns 404 (existing behaviour preserved)
+(epoch 28)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
+
+;; GET /actors/bob/outbox carries bob's id in the stub body
+(epoch 29)
+(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,98,111,98,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), [{status, 200}, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,98,111,98>>, B) =/= nomatch\") :name)")
+
+;; Accept: application/json on /actors/alice/outbox -> JSON stub
+(epoch 30)
+(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34>>, B) =/= nomatch\") :name)")
+
+;; Accept: application/sx on /actors/alice/inbox -> SX stub
+(epoch 31)
+(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<40,105,110,98,111,120,32>>, B) =/= nomatch\") :name)")
+
+;; Accept: application/json on /actors/alice/followers -> JSON stub
+(epoch 32)
+(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,101,114,115>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,102,111,108,108,111,119,101,114,115,34>>, B) =/= nomatch\") :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  2  "http_server loaded"                "http_server"
+check 10  "split sans slash returns bare"     "true"
+check 11  "split id/sub returns {id, sub}"    "true"
+check 12  "split id/ returns {id, <<>>}"      "true"
+check 20  "GET /actors/ regression"       "true"
+check 21  "GET /actors//outbox stub"      "true"
+check 22  "GET /actors//inbox stub"       "true"
+check 23  "GET /actors//followers stub"   "true"
+check 24  "GET /actors//following stub"   "true"
+check 25  "POST /actors//inbox -> 202"    "true"
+check 26  "GET /actors// -> 404"     "true"
+check 27  "POST /actors// -> 404"    "true"
+check 28  "GET /actors/ (empty) -> 404"       "true"
+check 29  "outbox body carries actor id"      "true"
+check 30  "outbox JSON content negotiation"   "true"
+check 31  "inbox SX content negotiation"      "true"
+check 32  "followers JSON content negotiation" "true"
+
+TOTAL=$((PASS+FAIL))
+if [ $FAIL -eq 0 ]; then
+  echo "ok $PASS/$TOTAL next/tests/http_multi_actor.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 a8520c84..85f7de61 100644
--- a/plans/fed-sx-milestone-2.md
+++ b/plans/fed-sx-milestone-2.md
@@ -260,21 +260,35 @@ token; the token now maps to an `:actor_id` rather than a fixed `alice`.
 
 **Deliverables:**
 
-- New route prefixes: `/actors//inbox`, `/actors//followers`,
-  `/actors//following`.
-- `http_server:route/3` (Cfg → Cfg+Kernel) so handlers can look up
-  actor state.
-- Cfg's `:publish_token` becomes `:tokens => #{Token => ActorId}` map.
-- `cid_response_for/2` already format-aware; per-actor outbox listing
-  uses the same machinery.
-
-**Tests:**
-
-- GET /actors/alice → 200 with actor doc.
-- GET /actors/unknown → 404.
-- POST /activity with alice's token publishes to alice.
-- POST /activity with bob's token publishes to bob.
-- Two actors' outboxes are independent.
+- [x] **4a** — Per-actor URL routing. New `split_first_slash/1`
+  helper splits the `/actors/` suffix into `{Id, SubPath}`.
+  GET dispatch routes `outbox` / `inbox` / `followers` / `following`
+  sub-paths to four new content-negotiated response functions
+  (`actor_outbox_response_for/2`, `actor_inbox_get_response_for/2`,
+  `actor_followers_response_for/2`,
+  `actor_following_response_for/2`) — text / json / activity_json /
+  sx variants per existing format pattern. POST dispatch routes
+  `inbox` to a 202 Accepted stub (`actor_inbox_post_response/0` +
+  `accepted_response/1`). Unknown sub-paths under `/actors//`
+  return 404. Bare `/actors/` keeps the M1 `actor_doc_response_for`
+  arm. 17 cases in `http_multi_actor.sh`.
+- [ ] **4b** — Token → ActorId map. Cfg's `:publish_token` becomes
+  `:tokens` (proplist `[{Token, ActorId}, ...]`). POST /activity
+  resolves the bearer token to an actor id and routes through
+  `nx_kernel:publish_to/2` instead of `publish/1`. Multi-token
+  Cfg keeps the M1 `:publish_token` single-token field as a
+  back-compat alias for one token mapping to `alice`.
+- [ ] **4c** — `http_server:route/3(Req, Cfg, Kernel)` — the Cfg
+  carries opaque `:kernel` reference (or accepts the registered
+  `nx_kernel` atom) so per-actor handlers can call
+  `nx_kernel:state_for/1`, `actor_log_state/2`, projections etc.
+- [ ] **4d** — Per-actor outbox listing reads from the named
+  bucket's log entries via `nx_kernel:actor_log_state/2`, content-
+  negotiates as today (text / json / sx). `?page=N` pagination
+  layered on top using `log:replay/3`.
+- [ ] **4e** — POST /actors//inbox stays a 202 stub for 4a-4d.
+  Step 5 lands the real ingestion pipeline (sig verify + inbox-
+  bucket append + projection broadcast).
 
 **Acceptance:** `bash next/tests/http_multi_actor.sh` passes 14+ cases.
 
@@ -717,6 +731,22 @@ proceed.
 
 Newest first.
 
+- **2026-06-06** — Step 4a: per-actor HTTP sub-paths. New
+  `split_first_slash/1` helper lets GET / POST `/actors//...`
+  paths route on the sub-segment (`outbox`, `inbox`, `followers`,
+  `following`). Four new content-negotiated response stubs
+  (`actor_outbox_response_for/2`, `actor_inbox_get_response_for/2`,
+  `actor_followers_response_for/2`, `actor_following_response_for/2`)
+  with text / json / activity_json / sx variants, mirroring the
+  existing `actor_doc_response_for/2` shape. POST
+  `/actors//inbox` returns a 202 Accepted stub
+  (`actor_inbox_post_response/0` + `accepted_response/1`); real
+  ingestion pipeline lands in Step 5. Unknown sub-paths return
+  404. Bare `/actors/` keeps the M1 actor-doc arm intact —
+  `http_route` and `http_post_format` regression suites unchanged
+  (10/10 each). 17/17 in `http_multi_actor.sh`. Conformance
+  761/761 preserved. 120/120 across 10 Step-4-adjacent suites.
+
 - **2026-06-06** — Step 3 (closes Step 3): key rotation via Update.
   `actor_state.erl` `fold_update` routes patches through
   `apply_patch/3` which special-cases `{add_publicKey, KeyProplist}`