From e04a65d4007a2917c6023aecd2469667657a30de Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 14:59:59 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=204c=20=E2=80=94=20route/3=20?= =?UTF-8?q?with=20kernel=20access=20+=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http_server:route/3(Req, Cfg, Kernel) is the new extended entry point: folds the kernel reference (typically the registered nx_kernel atom) into Cfg as {kernel, Kernel}. route/2 is unchanged and stays the M1 surface. The dispatch chain gained Cfg threading all the way down: dispatch/3 -> dispatch/4 (M, P, F, Cfg) actor_get/2 -> actor_get/3 (Rest, F, Cfg) actor_subresource_get/3 -> /4 (Id, Sub, F, Cfg) actor_outbox_response_for/3 (new) reads :kernel from Cfg and, when the kernel atom is registered AND the actor exists, renders 'tip: ' alongside the actor id in text / JSON / SX content- negotiated bodies. Unknown actors or unregistered kernels fall back to the 4a stub. Inbox / followers / following handlers accept Cfg but ignore it for now — they layer real state lookup in 4d/4e/Step 5+. Substrate gotcha logged in the Progress log: try/of/catch around gen_server:call(nx_kernel, _) deadlocks in this port's scheduler (probably the catch frame's mask defers reply delivery). The live kernel_log_tip/2 helper does a bare call + integer guard instead. nx_kernel_multi.sh already proves bare gen_server:call into the same kernel works correctly. 8 new cases in next/tests/http_multi_actor.sh (33/33 total): - route/3 with registered kernel: outbox body includes tip=0 - tip advances after POST publish through route/3 + token map - unknown actor (ghost) falls back to 4a stub (no tip:) - unregistered kernel ref falls back to stub - JSON Accept renders {"outbox":"alice","tip":0} - SX Accept renders (outbox "alice" :tip 0) - Bob's outbox tip stays 0 while Alice publishes (per-actor) - route/2 path unchanged: no tip field in body Conformance 761/761. 121/121 across 10 Step-4-adjacent suites (http_multi_actor, http_route, http_publish, http_post_format, http_marshal, http_publish_fold, http_listen_bif, http_server_start, nx_kernel_multi, bootstrap_start, actor_lifecycle). --- next/kernel/http_server.erl | 138 ++++++++++++++++++++++++++------- next/tests/http_multi_actor.sh | 46 +++++++++++ plans/fed-sx-milestone-2.md | 35 ++++++++- 3 files changed, 187 insertions(+), 32 deletions(-) diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index e322474e..61a45145 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -1,6 +1,6 @@ -module(http_server). -export([start/1, start/2]). --export([route/1, route/2, ok_response/1, not_found_response/0, +-export([route/1, route/2, route/3, ok_response/1, not_found_response/0, welcome_body/0, capabilities_body/0, capabilities_path/0, match_prefix/2, actors_prefix/0, actor_doc_response/1, @@ -17,7 +17,8 @@ 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, - actor_outbox_response_for/2, actor_inbox_get_response_for/2, + actor_outbox_response_for/2, actor_outbox_response_for/3, + 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]). @@ -60,10 +61,10 @@ start(Port, Cfg) -> route(Req) -> route(Req, []). -%% route/2 — Cfg proplist carries optional `:publish_token` (binary) -%% for POST /activity auth. Other state (logs, projections, etc.) is -%% not yet threaded through — POST /activity returns a stub 200 -%% once auth succeeds; real outbox:publish glue lands separately. +%% route/2 — Cfg proplist carries optional `:publish_token` / +%% `:tokens` (POST /activity auth) and optional `:kernel` +%% (per-actor handlers — Step 4c). route/3 is sugar that puts +%% Kernel into Cfg. route(Req, Cfg) -> M = field(method, Req), P = field(path, Req), @@ -76,33 +77,43 @@ route(Req, Cfg) -> 47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>} -> ok_response(capabilities_body_for(F)); _ -> - dispatch(M, P, F) + dispatch(M, P, F, Cfg) end. +%% route/3 — Step 4c convenience entry. Kernel is an opaque +%% reference (typically the registered `nx_kernel` atom). It's +%% folded into Cfg under `:kernel` so handlers can look it up +%% without a separate threading argument. +route(Req, Cfg, Kernel) -> + route(Req, [{kernel, Kernel} | Cfg]). + %% Backward-compat /2 wrapper — defaults to text format. Route -%% computes Format from the Accept header and calls dispatch/3 -%% directly; dispatch/2 is kept for callers that don't have a -%% format in scope. +%% computes Format from the Accept header and calls dispatch/4 +%% directly; dispatch/2 and dispatch/3 are kept for callers that +%% don't have a format / Cfg in scope. dispatch(M, P) -> - dispatch(M, P, text). + dispatch(M, P, text, []). + +dispatch(M, P, F) -> + dispatch(M, P, F, []). %% 71 69 84 = "GET" | 47 = "/" -dispatch(<<71, 69, 84>>, <<47>>, _F) -> +dispatch(<<71, 69, 84>>, <<47>>, _F, _Cfg) -> ok_response(welcome_body()); %% GET /.well-known/sx-capabilities — Format threaded through dispatch(<<71, 69, 84>>, <<47,46,119,101,108,108,45,107,110,111,119,110, - 47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>, F) -> + 47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>, F, _Cfg) -> ok_response(capabilities_body_for(F)); %% GET /projections — list stub. Comes before the /projections/{name} %% 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) -> +dispatch(<<71, 69, 84>>, <<47,112,114,111,106,101,99,116,105,111,110,115>>, F, _Cfg) -> projections_list_response_for(F); %% GET /actors/{id}[/sub] or /artifacts/{cid} or /projections/{name} -dispatch(<<71, 69, 84>>, Path, F) -> +dispatch(<<71, 69, 84>>, Path, F, Cfg) -> case match_prefix(actors_prefix(), Path) of {ok, Rest} when byte_size(Rest) > 0 -> - actor_get(Rest, F); + actor_get(Rest, F, Cfg); _ -> case match_prefix(artifacts_prefix(), Path) of {ok, Cid} when byte_size(Cid) > 0 -> @@ -118,40 +129,41 @@ dispatch(<<71, 69, 84>>, Path, F) -> 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) -> +dispatch(<<80, 79, 83, 84>>, Path, _F, _Cfg) -> case match_prefix(actors_prefix(), Path) of {ok, Rest} when byte_size(Rest) > 0 -> actor_post(Rest); _ -> not_found_response() end; -dispatch(_, _, _) -> +dispatch(_, _, _, _) -> not_found_response(). -%% actor_get/2 — Rest is the part after "/actors/". If it has no +%% actor_get/3 — Rest is the part after "/actors/". If it has no %% inner slash, it's the bare actor doc. Otherwise dispatch on the -%% sub-segment. +%% sub-segment. Cfg flows through so sub-resource handlers can +%% read `:kernel` for per-actor state lookup (Step 4c). -actor_get(Rest, F) -> +actor_get(Rest, F, Cfg) -> case split_first_slash(Rest) of {Id, <<>>} -> actor_doc_response_for(Id, F); - {Id, Sub} -> actor_subresource_get(Id, Sub, F); + {Id, Sub} -> actor_subresource_get(Id, Sub, F, Cfg); 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); +actor_subresource_get(Id, <<111,117,116,98,111,120>>, F, Cfg) -> + actor_outbox_response_for(Id, F, Cfg); %% 105 110 98 111 120 = "inbox" -actor_subresource_get(Id, <<105,110,98,111,120>>, F) -> +actor_subresource_get(Id, <<105,110,98,111,120>>, F, _Cfg) -> 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_subresource_get(Id, <<102,111,108,108,111,119,101,114,115>>, F, _Cfg) -> 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_subresource_get(Id, <<102,111,108,108,111,119,105,110,103>>, F, _Cfg) -> actor_following_response_for(Id, F); -actor_subresource_get(_, _, _) -> +actor_subresource_get(_, _, _, _) -> not_found_response(). actor_post(Rest) -> @@ -657,6 +669,76 @@ actor_outbox_response_for(Id, _) -> Pre = <<111,117,116,98,111,120,58,32>>, ok_response(<
>).
 
+%% actor_outbox_response_for/3 — Step 4c kernel-aware variant. When
+%% Cfg carries a `:kernel` reference *and* the kernel has the actor,
+%% include "tip: \n" after the bare body so callers can verify
+%% the route landed on the right bucket. Falls back to the /2 stub
+%% otherwise — same shape, same content-negotiation arms.
+
+actor_outbox_response_for(Id, F, Cfg) ->
+    case field(kernel, Cfg) of
+        nil ->
+            actor_outbox_response_for(Id, F);
+        Kernel ->
+            case kernel_log_tip(Kernel, Id) of
+                nil ->
+                    actor_outbox_response_for(Id, F);
+                Tip ->
+                    actor_outbox_with_tip_response_for(Id, F, Tip)
+            end
+    end.
+
+%% kernel_log_tip/2 — query the kernel for an actor's log tip via
+%% `nx_kernel:log_tip_for/1`. Returns the tip integer when the actor
+%% exists, `nil` when the kernel atom isn't registered or the actor
+%% isn't present. Catches everything so a stale Cfg can't break the
+%% handler.
+
+kernel_log_tip(Kernel, Id) when is_atom(Kernel) ->
+    case erlang:whereis(Kernel) of
+        undefined -> nil;
+        _ ->
+            L = binary_to_list(Id),
+            A = list_to_atom(L),
+            T = nx_kernel:log_tip_for(A),
+            case T of
+                N when is_integer(N) -> N;
+                _                    -> nil
+            end
+    end;
+kernel_log_tip(_, _) -> nil.
+
+actor_outbox_with_tip_response_for(Id, text, Tip) ->
+    %% "outbox: \ntip: \n"
+    Pre  = <<111,117,116,98,111,120,58,32>>,           % "outbox: "
+    Tipp = <<10,116,105,112,58,32>>,                   % "\ntip: "
+    TipBin = list_to_binary(integer_to_list(Tip)),
+    Body = <
>,
+    ok_response(Body);
+actor_outbox_with_tip_response_for(Id, json, Tip) ->
+    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
+    Mid = <<34,44,34,116,105,112,34,58>>,              % '","tip":'
+    Suf = <<125,10>>,                                  % '}\n'
+    TipBin = list_to_binary(integer_to_list(Tip)),
+    Body = <
>,
+    ok_response(Body, json);
+actor_outbox_with_tip_response_for(Id, activity_json, Tip) ->
+    Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
+    Mid = <<34,44,34,116,105,112,34,58>>,
+    Suf = <<125,10>>,
+    TipBin = list_to_binary(integer_to_list(Tip)),
+    Body = <
>,
+    ok_response(Body, activity_json);
+actor_outbox_with_tip_response_for(Id, sx, Tip) ->
+    Pre = <<40,111,117,116,98,111,120,32,34>>,         % '(outbox "'
+    Mid = <<34,32,58,116,105,112,32>>,                 % '" :tip '
+    Suf = <<41,10>>,                                   % ')\n'
+    TipBin = list_to_binary(integer_to_list(Tip)),
+    Body = <
>,
+    ok_response(Body, sx);
+actor_outbox_with_tip_response_for(Id, _, Tip) ->
+    actor_outbox_with_tip_response_for(Id, text, Tip).
+
 %% "inbox: " — 7 bytes
 actor_inbox_get_response_for(Id, text) ->
     Pre = <<105,110,98,111,120,58,32>>,
diff --git a/next/tests/http_multi_actor.sh b/next/tests/http_multi_actor.sh
index bd7597a8..0ae87d99 100755
--- a/next/tests/http_multi_actor.sh
+++ b/next/tests/http_multi_actor.sh
@@ -146,6 +146,44 @@ cat > "$TMPFILE" <<'EPOCHS'
 (epoch 46)
 (eval "(get (erlang-eval-ast \"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}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, LegacyTok = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, LegacyAuth = <<66,101,97,114,101,114,32,102,111,111>>, Cfg = [{tokens, [{AliceTok, alice}]}, {publish_token, LegacyTok}], Req1 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], Req2 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, LegacyAuth}]}, {body, <<104,105>>}], http_server:route(Req1, Cfg), http_server:route(Req2, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {2, 0}\") :name)")
 
+;; ── Step 4c: route/3 with kernel access ───────────────────────
+;; route/3 folds the Kernel into Cfg under :kernel. The outbox
+;; sub-resource handler now reads :kernel and includes "tip: N"
+;; when the actor exists in the kernel. Other handlers ignore the
+;; field for now (they layer real state in 4d/4e).
+
+;; route/3 with kernel reference: GET /actors/alice/outbox includes log tip
+(epoch 50)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), 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, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,48>>, B) =/= nomatch\") :name)")
+
+;; route/3 with kernel reference: outbox tip advances after publish
+(epoch 51)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{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(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49>>, B) =/= nomatch\") :name)")
+
+;; route/3 with unknown actor -> falls back to /2 stub (no tip)
+(epoch 52)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,103,104,111,115,116,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,103,104,111,115,116,10>>, B) =/= nomatch andalso http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
+
+;; route/3 without kernel registered -> falls back to stub
+(epoch 53)
+(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, [], unregistered_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10>>, B) =/= nomatch andalso http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
+
+;; route/3 with kernel + JSON Accept -> JSON body carries :tip
+(epoch 54)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), 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, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34,58,34,97,108,105,99,101,34,44,34,116,105,112,34,58,48>>, B) =/= nomatch\") :name)")
+
+;; route/3 with kernel + SX Accept -> SX body carries :tip
+(epoch 55)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), 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,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<40,111,117,116,98,111,120,32,34,97,108,105,99,101,34,32,58,116,105,112,32,48,41>>, B) =/= nomatch\") :name)")
+
+;; route/3 with kernel + multi-actor: bob's outbox tip is independent
+(epoch 56)
+(eval "(get (erlang-eval-ast \"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}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{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(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,98,111,98,10,116,105,112,58,32,48>>, B) =/= nomatch\") :name)")
+
+;; route/2 path (no kernel arg) still returns the 4a stub — back-compat
+(epoch 57)
+(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), 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, []), [_, _, {body, B}] = R, http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
+
 ;; Token resolution before kernel is registered -> auth-stub published response
 (epoch 47)
 (eval "(get (erlang-eval-ast \"AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<112,117,98,108,105,115,104,101,100>>, B) =/= nomatch; _ -> false end\") :name)")
@@ -197,6 +235,14 @@ check 44  "unknown token -> 401"               "true"
 check 45  "legacy :publish_token still works"  "true"
 check 46  "tokens map + legacy back-compat"    "true"
 check 47  "no kernel + token map -> stub 200"  "true"
+check 50  "route/3 outbox includes tip = 0"    "true"
+check 51  "tip advances after publish"         "true"
+check 52  "unknown actor -> stub fallback"     "true"
+check 53  "unregistered kernel -> stub"        "true"
+check 54  "JSON outbox carries tip field"      "true"
+check 55  "SX outbox carries :tip field"       "true"
+check 56  "Bob outbox tip independent"         "true"
+check 57  "route/2 unchanged (no tip)"         "true"
 
 TOTAL=$((PASS+FAIL))
 if [ $FAIL -eq 0 ]; then
diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md
index be78c330..6c8cefdd 100644
--- a/plans/fed-sx-milestone-2.md
+++ b/plans/fed-sx-milestone-2.md
@@ -285,10 +285,21 @@ token; the token now maps to an `:actor_id` rather than a fixed `alice`.
   No-kernel auth-only path unchanged. The dead M1
   `expected_token/1` helper is gone. 8 new cases in
   `http_multi_actor.sh` (25/25 total).
-- [ ] **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.
+- [x] **4c** — `http_server:route/3(Req, Cfg, Kernel)` is sugar
+  that folds the Kernel reference (typically the registered
+  `nx_kernel` atom) into Cfg as `{kernel, Kernel}`. The dispatch
+  chain gained a Cfg arg threaded all the way to per-actor
+  sub-resource handlers (`dispatch/3` → `dispatch/4`, `actor_get/2`
+  → `actor_get/3`, `actor_subresource_get/3` → /4). The outbox
+  sub-resource handler now reads `:kernel` and, when the actor
+  exists in the kernel, renders `tip: ` in text / JSON / SX
+  variants — proving the plumbing works end-to-end. Unknown
+  actors or unregistered kernels fall back to the 4a stub.
+  `try`/`of`/`catch` around `gen_server:call` deadlocks in this
+  port's scheduler (probably the catch-frame mask defers reply
+  delivery); the live handler does a bare `nx_kernel:log_tip_for/1`
+  + integer guard instead. 8 new cases in `http_multi_actor.sh`
+  (33/33 total).
 - [ ] **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
@@ -738,6 +749,22 @@ proceed.
 
 Newest first.
 
+- **2026-06-06** — Step 4c: route/3 with kernel access.
+  `http_server:route/3(Req, Cfg, Kernel)` folds the kernel
+  reference into Cfg as `{kernel, _}`. Dispatch chain refactored
+  to thread Cfg through to per-actor sub-resource handlers.
+  Outbox handler reads `:kernel` and renders `tip: ` (in
+  text / JSON / SX content-negotiated variants) when the actor
+  exists; falls back to the 4a stub otherwise. Substrate quirk
+  found: `try`/`of`/`catch` around `gen_server:call` deadlocks
+  in this port's scheduler — bare call + integer guard works.
+  Inbox / followers / following handlers accept Cfg but ignore
+  it; real state lookup lands in 4d/4e/Step 5+. 8 new cases in
+  `http_multi_actor.sh` (33/33 total). Conformance 761/761.
+  121/121 across 10 Step-4-adjacent suites. **Gotcha** noted
+  for future iterations: avoid try/catch wrapping gen_server
+  calls in this substrate.
+
 - **2026-06-06** — Step 4b: token -> ActorId map. Cfg's `:tokens`
   proplist (`[{Token, ActorId}, ...]`) maps bearer tokens to
   per-actor publishers. `handle_post_activity` threads the