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`