diff --git a/next/kernel/discovery_fetch.erl b/next/kernel/discovery_fetch.erl new file mode 100644 index 00000000..14f6cb9a --- /dev/null +++ b/next/kernel/discovery_fetch.erl @@ -0,0 +1,89 @@ +-module(discovery_fetch). +-export([make_fetch_fn/1, + fetch/2, + actor_doc_url/2, + decode_body/1, + accept_header/0]). + +%% Live peer-actor-doc fetch for peer_actors — Step 10c per design +%% §13.6. The peer_actors gen_server already exposes +%% lookup_or_fetch_srv/2(PeerId, FetchFn) where FetchFn is a +%% 1-arity closure that returns {ok, PeerAS} | {error, Reason} on +%% cache miss. For tests we wire a fake FetchFn that returns a +%% pre-baked AS; for live federation we wire the closure this +%% module produces — it GETs /actors/ with an Accept +%% header that asks for the actor_doc format +%% (http_server.erl Step 10c), decodes the response body via +%% term_codec, and returns the AS proplist. +%% +%% Cfg shape (reuses dispatch_http's peer URL resolution so a +%% single Cfg threads through both delivery and discovery): +%% {peer_url, [{PeerId, BaseUrl}, ...]} +%% {peer_url_fn, fun ((PeerId) -> {ok, BaseUrl} | not_found)} +%% +%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this +%% module appends the path). PeerId is the actor atom. +%% +%% Outcomes: +%% 2xx + decodable body -> {ok, PeerAS} +%% 2xx + bad body -> {error, bad_actor_doc} +%% non-2xx -> {error, {status, N}} +%% resolver miss -> {error, no_peer_url} +%% transport -> {error, Reason} +%% +%% Cache write semantics live in peer_actors:lookup_or_fetch/3 — +%% successful fetches store; errors do NOT poison so callers can +%% retry on transients. + +%% ── Accept header ──────────────────────────────────────────── +%% "application/vnd.fed-sx.actor-doc" — same MIME the http_server +%% content_type_for(actor_doc) emits, so the Accept negotiation +%% in accept_format/1 routes the peer's response to the term_codec +%% serializer arm. +accept_header() -> + <<97,112,112,108,105,99,97,116,105,111,110,47, + 118,110,100,46,102,101,100,45,115,120,46, + 97,99,116,111,114,45,100,111,99>>. + +%% ── public API ─────────────────────────────────────────────── + +make_fetch_fn(Cfg) -> + fun (PeerId) -> + case dispatch_http:resolve_peer_url(PeerId, Cfg) of + {error, R} -> {error, R}; + {ok, BaseUrl} -> fetch(actor_doc_url(BaseUrl, PeerId), Cfg) + end + end. + +fetch(Url, _Cfg) -> + AcceptKey = <<97,99,99,101,112,116>>, % "accept" + Headers = [{AcceptKey, accept_header()}], + try httpc:request(Url, get, Headers, <<>>) of + {ok, Status, _H, Body} when Status >= 200, Status < 300 -> + decode_body(Body); + {ok, Status, _H, _B} -> + {error, {status, Status}}; + Other -> + {error, {bad_response, Other}} + catch + error:Reason -> {error, Reason} + end. + +%% actor_doc_url/2 — /actors/. PeerId is the actor +%% atom; rendered to a binary via its name (matches the same path +%% layout http_server.erl uses for the route registration at +%% prefix "/actors/"). +actor_doc_url(BaseUrl, PeerId) when is_atom(PeerId) -> + PeerBin = list_to_binary(atom_to_list(PeerId)), + %% "/actors/" — 8 bytes + Prefix = <<47,97,99,116,111,114,115,47>>, + <>. + +%% decode_body/1 — round the wire body back through term_codec. +%% Returns {ok, AS} on a proplist-shaped decode (matching the +%% peer-actor-state schema), {error, bad_actor_doc} otherwise. +decode_body(Body) -> + case term_codec:decode(Body) of + {ok, AS, _} when is_list(AS) -> {ok, AS}; + _ -> {error, bad_actor_doc} + end. diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index 5867b4d8..1ac55d73 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -15,7 +15,8 @@ capabilities_body_for/1, 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, + actor_doc_response_for/2, actor_doc_response_for/3, + artifact_response_for/2, projection_response_for/2, projections_list_response_for/1, actor_outbox_response_for/2, actor_outbox_response_for/3, actor_inbox_get_response_for/2, @@ -172,9 +173,9 @@ dispatch(_, _, _, _) -> actor_get(Rest, F, Cfg) -> case split_first_slash(Rest) of - {Id, <<>>} -> actor_doc_response_for(Id, F); + {Id, <<>>} -> actor_doc_response_for(Id, F, Cfg); {Id, Sub} -> actor_subresource_get(Id, Sub, F, Cfg); - Id -> actor_doc_response_for(Id, F) + Id -> actor_doc_response_for(Id, F, Cfg) end. %% 111 117 116 98 111 120 = "outbox" @@ -481,21 +482,31 @@ sx_prefix() -> cbor_prefix() -> <<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>. +%% "application/vnd.fed-sx.actor-doc" — 32 bytes (Step 10c) +actor_doc_prefix() -> + <<97,112,112,108,105,99,97,116,105,111,110,47, + 118,110,100,46,102,101,100,45,115,120,46, + 97,99,116,111,114,45,100,111,99>>. + accept_format(nil) -> text; accept_format(<<>>) -> text; accept_format(V) when is_binary(V) -> - case match_prefix(activity_json_prefix(), V) of - {ok, _} -> activity_json; + case match_prefix(actor_doc_prefix(), V) of + {ok, _} -> actor_doc; _ -> - case match_prefix(json_prefix(), V) of - {ok, _} -> json; + case match_prefix(activity_json_prefix(), V) of + {ok, _} -> activity_json; _ -> - case match_prefix(sx_prefix(), V) of - {ok, _} -> sx; + case match_prefix(json_prefix(), V) of + {ok, _} -> json; _ -> - case match_prefix(cbor_prefix(), V) of - {ok, _} -> cbor; - _ -> text + case match_prefix(sx_prefix(), V) of + {ok, _} -> sx; + _ -> + case match_prefix(cbor_prefix(), V) of + {ok, _} -> cbor; + _ -> text + end end end end @@ -564,6 +575,17 @@ content_type_for(sx) -> content_type_for(cbor) -> <<97,112,112,108,105,99,97,116,105,111,110,47, 99,98,111,114>>; +%% "application/vnd.fed-sx.actor-doc" — 32 bytes. Step 10c content +%% type for term_codec-encoded peer-actor docs; the federation fetch +%% layer (discovery_fetch.erl) uses this Accept header to ask for a +%% peer's :public_keys (and v3+ profile fields) in a wire-decodable +%% form. Distinct from application/vnd.fed-sx.activity (dispatch_http +%% Step 8f) because the body is a peer-actor-state proplist, not a +%% signed activity envelope. +content_type_for(actor_doc) -> + <<97,112,112,108,105,99,97,116,105,111,110,47, + 118,110,100,46,102,101,100,45,115,120,46, + 97,99,116,111,114,45,100,111,99>>; content_type_for(_) -> content_type_for(text). @@ -660,6 +682,38 @@ actor_doc_response_for(Id, cbor) -> actor_doc_response_for(Id, _) -> actor_doc_response(Id). +%% Step 10c kernel-aware variant. The `actor_doc` format negotiates +%% to a term_codec-encoded peer-actor-state proplist (currently just +%% `[{public_keys, [...]}]`) so a federated peer running +%% discovery_fetch.erl can decode it directly into the shape +%% peer_actors and envelope:verify_signature consume. Other formats +%% fall through to the /2 stub variants. + +actor_doc_response_for(Id, actor_doc, Cfg) -> + case kernel_actor_state(field(kernel, Cfg), Id) of + nil -> not_found_response(); + AS -> ok_response(term_codec:encode(AS), actor_doc) + end; +actor_doc_response_for(Id, F, _Cfg) -> + actor_doc_response_for(Id, F). + +%% kernel_actor_state/2 — bridge to nx_kernel:state_for/1 (the +%% server-side variant of actor_state/2). Cfg carries the kernel +%% module atom (currently always `nx_kernel`); Id is a binary so +%% we round-trip through list_to_atom. This port's Erlang doesn't +%% support `Mod:Fun(X)` dispatch on a variable module, so we +%% hardcode nx_kernel (the only kernel module in play); the Cfg +%% field exists to flag "no kernel wired" -> nil short-circuit. +%% nx_kernel:actor_state/1 is the legacy single-bucket accessor +%% that takes State, not ActorId — wrong shape here. +kernel_actor_state(nil, _Id) -> nil; +kernel_actor_state(_Kernel, Id) -> + Atom = list_to_atom(binary_to_list(Id)), + case nx_kernel:state_for(Atom) of + {ok, AS} -> AS; + _ -> nil + end. + %% ── 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 diff --git a/next/tests/discovery_fetch.sh b/next/tests/discovery_fetch.sh new file mode 100755 index 00000000..8deb6ad7 --- /dev/null +++ b/next/tests/discovery_fetch.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# next/tests/discovery_fetch.sh — m2 Step 10c acceptance test. +# +# Two halves: +# (a) http_server side: the new actor_doc Accept format negotiates +# to a term_codec-encoded peer-actor-state proplist served +# from `nx_kernel:actor_state/1`. Verified via http_server:route +# in-process. +# (b) discovery_fetch closure: builds the FetchFn that +# peer_actors:lookup_or_fetch_srv/2 expects, GETs the actor +# doc via httpc:request/4, decodes the body, returns the AS +# proplist. Verified end-to-end against a background +# `python3 -m http.server`-style stub that returns hand-crafted +# term_codec bytes (so we exercise the wire, not just the +# in-process route). + +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="" + +# ── live stub server ───────────────────────────────────────── +# Python script that: +# GET /actors/alice -> 200 with term_codec-encoded AS +# (built in Python: matches term_codec +# netstring format spelled out in +# next/kernel/term_codec.erl). +# GET /actors/missing -> 404 +PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()') +SRVROOT=$(mktemp -d) +PYSRV="$SRVROOT/srv.py" +cat > "$PYSRV" <<'PY' +import sys, http.server, socketserver + +PORT = int(sys.argv[1]) + +# term_codec encoding (mirror of next/kernel/term_codec.erl). +def enc_atom(s): + b = s.encode() + return f"a{len(b)}:".encode() + b +def enc_int(n): + s = str(n).encode() + return f"i{len(s)}:".encode() + s +def enc_bin(b): + return f"b{len(b)}:".encode() + b +def enc_list(items): + payload = b"".join(items) + # term_codec uses ELEMENT COUNT (not byte length) for list/tuple + # headers — see encode/1 in next/kernel/term_codec.erl. + return f"l{len(items)}:".encode() + payload +def enc_tuple(items): + payload = b"".join(items) + return f"t{len(items)}:".encode() + payload +def enc_nil(): + return b"l0:" + +# {public_keys, [[{id, k1}, {created, 0}, {value, <<1,2,3,4>>}]]} +KEY = enc_list([ + enc_tuple([enc_atom("id"), enc_atom("k1")]), + enc_tuple([enc_atom("created"), enc_int(0)]), + enc_tuple([enc_atom("value"), enc_bin(bytes([1,2,3,4]))]), +]) +PROPLIST = enc_list([ + enc_tuple([enc_atom("public_keys"), enc_list([KEY])]), +]) + +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/actors/alice": + self.send_response(200) + self.send_header('content-type','application/vnd.fed-sx.actor-doc') + self.send_header('content-length', str(len(PROPLIST))) + self.end_headers() + self.wfile.write(PROPLIST) + else: + self.send_response(404); self.end_headers(); self.wfile.write(b'not found') + def log_message(self, fmt, *args): pass + +with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + srv.serve_forever() +PY +python3 "$PYSRV" "$PORT" >/dev/null 2>&1 & +SRV_PID=$! +TMPFILE=$(mktemp) +trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT +for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if curl -fsS "http://127.0.0.1:$PORT/actors/alice" >/dev/null 2>&1; then break; fi + sleep 0.2 +done + +bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; } +URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT") + +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/term_codec.erl\")) :name)") +(epoch 7) +(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)") +(epoch 8) +(eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)") +(epoch 9) +(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)") +(epoch 10) +(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)") +(epoch 11) +(eval "(get (erlang-load-module (file-read \"next/kernel/dispatch_http.erl\")) :name)") +(epoch 12) +(eval "(get (erlang-load-module (file-read \"next/kernel/discovery_fetch.erl\")) :name)") + +;; (a) http_server side: actor_doc Accept negotiates to actor_doc +(epoch 20) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,97,99,116,111,114,45,100,111,99>>) =:= actor_doc\") :name)") + +;; (a) actor_doc_response_for/3 with kernel + actor returns 200 + +;; term_codec body; decoded body has :public_keys. Inline SETUP +;; per epoch because separate (eval ...) calls share gen_server +;; state but not Erlang locals, and we need fresh kernel-aware +;; assertions even though the previous epoch's nx_kernel persists. +(epoch 21) +(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), Cfg = [{kernel, nx_kernel}], R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)") + +;; (a) body decodes to a proplist with :public_keys +(epoch 22) +(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), R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, [{kernel, nx_kernel}]), {ok, Body} = envelope:get_field(body, R), {ok, AS, _} = term_codec:decode(Body), case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end\") :name)") + +;; (a) unknown actor -> 404 +(epoch 23) +(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), R = http_server:actor_doc_response_for(<<109,105,115,115,105,110,103>>, actor_doc, [{kernel, nx_kernel}]), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)") + +;; (b) discovery_fetch:actor_doc_url builds /actors/alice +(epoch 30) +(eval "(get (erlang-eval-ast \"U = discovery_fetch:actor_doc_url(<<__URL_BASE__>>, alice), U =:= <<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>\") :name)") + +;; (b) discovery_fetch:fetch live -> {ok, AS} with :public_keys +(epoch 31) +(eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>, []), case R of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)") + +;; (b) closure produced by make_fetch_fn dispatches ok +(epoch 32) +(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), case Fn(alice) of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)") + +;; (b) closure on missing peer -> {error, no_peer_url} +(epoch 33) +(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, []}]), case Fn(alice) of {error, no_peer_url} -> true; _ -> false end\") :name)") + +;; (b) closure GETs 404 path -> {error, {status, 404}} +(epoch 34) +(eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,109,105,115,115,105,110,103>>, []), case R of {error, {status, 404}} -> true; _ -> false end\") :name)") + +;; (b) lookup_or_fetch on cache miss writes the result back +(epoch 35) +(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), {R, NewState} = case peer_actors:lookup_or_fetch(alice, Fn, peer_actors:new()) of {ok, _AS, S} -> {ok, S}; {error, R0, S} -> {error, S} end, R =:= ok andalso peer_actors:peers(NewState) =:= [alice]\") :name)") +EPOCHS + +sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g" "$TMPFILE" + +OUTPUT=$(timeout 360 "$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 12 "discovery_fetch loaded" "discovery_fetch" +check 20 "actor_doc Accept negotiates" "true" +check 21 "actor_doc /3 with kernel -> 200" "true" +check 22 "body decodes to proplist w/ :public_keys" "true" +check 23 "unknown actor -> 404" "true" +check 30 "actor_doc_url builds /actors/X" "true" +check 31 "fetch live -> {ok, AS}" "true" +check 32 "closure -> {ok, AS}" "true" +check 33 "closure on missing peer -> no_peer_url" "true" +check 34 "closure on 404 -> {status, 404}" "true" +check 35 "lookup_or_fetch caches result" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/discovery_fetch.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 1c8b7f11..2731fb61 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -754,11 +754,25 @@ Per §13.7: webfinger plus actor doc fetch. (no-kernel, with-kernel, host-match), 404 paths (missing-resource, bad-acct, unknown-actor, host-mismatch, wrong-method). -- [ ] **10c** — Peer-actor fetch + cache write. Gates on - Blockers #2 (native `http-request` primitive missing). - Step 5's peer_actors cache already exposes the - `lookup_or_fetch` shape; this Step 10c plugs the discovery - HTTP fetch into the FetchFn slot. +- [x] **10c** — Peer-actor fetch + cache write. New + `discovery_fetch.erl` produces a 1-arity FetchFn closure + suitable for `peer_actors:lookup_or_fetch_srv/2`: GETs + `/actors/` with + `Accept: application/vnd.fed-sx.actor-doc`, decodes the body + via `term_codec:decode/1`, and returns `{ok, AS}` where AS is + the peer's `[{public_keys, [...]}]` proplist + (`envelope:verify_signature` shape). Cfg reuses the same + `:peer_url` / `:peer_url_fn` resolution as `dispatch_http`. + Server side: http_server now serves the same MIME — new + `actor_doc` content-negotiation atom, `actor_doc_response_for/3` + kernel-aware arm calls `nx_kernel:state_for/1` and emits the + `term_codec:encode/1` of the AS. Test: + `next/tests/discovery_fetch.sh` 11/11 — Accept negotiation, + server-side encode (with kernel) → 200 + decodable body, + unknown actor → 404, URL construction, live fetch + + decode, closure resolution (static map + closure peer + resolver), missing peer → `no_peer_url`, 404 → `{status, 404}`, + end-to-end `peer_actors:lookup_or_fetch` cache write. **Tests:** @@ -1072,6 +1086,63 @@ proceed. Newest first. +- **2026-06-07** — Step 10c (closes Step 10): peer-actor doc + fetch + cache write. New `next/kernel/discovery_fetch.erl` + produces a 1-arity FetchFn closure for + `peer_actors:lookup_or_fetch_srv/2`. Closure GETs + `/actors/` via Step 8e's `httpc:request/4` BIF + with `Accept: application/vnd.fed-sx.actor-doc`, decodes + the body via `term_codec:decode/1`, returns `{ok, AS}` where + AS is the peer-actor-state proplist (`[{public_keys, [...]}]`, + the shape `envelope:verify_signature` consumes). Cfg reuses + the same `:peer_url` / `:peer_url_fn` resolution as + `dispatch_http` (Step 8f) so a single Cfg can thread through + both delivery and discovery. + + Server side: `http_server.erl` now serves the same MIME. + New `actor_doc` content-negotiation atom — `accept_format/1` + matches `application/vnd.fed-sx.actor-doc` first + (`actor_doc_prefix/0`); `content_type_for(actor_doc)` + emits it on outbound. New `actor_doc_response_for/3` + kernel-aware arm: when Cfg carries `{kernel, Kernel}` and + the kernel has the actor, calls `nx_kernel:state_for/1` + (NOT the legacy single-bucket `actor_state/1` accessor) and + emits `term_codec:encode/1` of the AS. Other formats fall + through to the existing /2 stub variants. Unknown actor → + `not_found_response/0`. `actor_get/3` route dispatch now + threads Cfg through to the /3 arm. + + Subtle port note: this port's Erlang doesn't support + `Mod:Fun(X)` dispatch on a variable module, so the + Cfg `:kernel` field exists to flag "no kernel wired" → + nil short-circuit; the actual call is hardcoded to + `nx_kernel:state_for/1` (the only kernel module in play). + Documented inline. + + Outcome mapping (discovery_fetch): + 2xx + decodable → {ok, AS} + 2xx + bad body → {error, bad_actor_doc} + non-2xx → {error, {status, N}} + resolver miss → {error, no_peer_url} + transport → {error, Reason} (BIF's network re-raise) + + Test: `next/tests/discovery_fetch.sh` 11/11 — both halves. + Server side: Accept negotiation, kernel + actor → 200 + + decodable body, unknown actor → 404. Closure side: URL + construction `/actors/`, live GET against the + background python stub returning hand-crafted term_codec + bytes (Python encoding helper mirrors term_codec.erl's + netstring format — count-based not byte-length headers for + l/t), make_fetch_fn closure resolves through static map + + closure peer_url_fn, missing peer → `no_peer_url`, 404 → + `{status, 404}`, end-to-end `peer_actors:lookup_or_fetch/3` + caches the result. + + Adjacent gates: Erlang conformance 761/761, httpc_request + 10/10, dispatch_http 10/10, http_listen_bif 5/5, + peer_actors 19/19, discovery 12/12, http_accept 13/13, + http_actors 13/13 — all green. + - **2026-06-07** — Step 8f (closes Step 8 except 8b-timer which still gates on Blockers #3 send_after): live HTTP dispatch through `httpc:request/4`. New `next/kernel/dispatch_http.erl`