From 6a9bd054c740b70511c3e68a48f1eba9b971d8f8 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 09:46:24 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=201a=20=E2=80=94=20nx=5Fkerne?= =?UTF-8?q?l=20per-actor=20bucket=20refactor=20+=2017=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State shape becomes [{actors, [{Id, Bucket}, ...]}, {next_actor_seq, N}] with ActorBucket = [{key_spec, KS}, {actor_state, AS}, {log, L}, {projections, [Name]}, {next_published, N}]. Pure-functional multi- actor APIs (new/0, add_actor/4, has_actor/2, actors/1, actor_count/1, publish/3, per-actor accessors, with_actor_projections/3) join the legacy single-actor accessors, which now read from the first bucket. Every M1 test continues to pass via bootstrap:start/3 -> new/3 -> first-bucket lookup. Local has_keyed/find_keyed/set_keyed/set_bucket helpers cover the keyed-list ops since lists:keymember/keyfind aren't registered in this substrate. next/tests/nx_kernel_multi.sh 17/17. M1 nx_kernel-adjacent suites green (bootstrap_start 10/10, nx_kernel_server 11/11, http_publish 10/10, smoke_app_pure 12/12, http_post_format 13/13, http_publish_fold 10/10, http_marshal 10/10). Erlang conformance 761/761 preserved. Blockers entry added for pre-existing http_server_tcp.sh 0/5 regression (78eae9ef left dead helper references in runtime.sx:1593) — substrate-side, out of m2 scope, confirmed pre-existing by reverting 1a's changes and re-running. --- next/README.md | 2 +- next/kernel/nx_kernel.erl | 266 +++++++++++++++++++++++++++------- next/tests/nx_kernel_multi.sh | 159 ++++++++++++++++++++ plans/fed-sx-milestone-2.md | 100 +++++++++---- 4 files changed, 440 insertions(+), 87 deletions(-) create mode 100755 next/tests/nx_kernel_multi.sh diff --git a/next/README.md b/next/README.md index 2f77bfbb..4099306e 100644 --- a/next/README.md +++ b/next/README.md @@ -43,7 +43,7 @@ next/ | `bootstrap.erl` | Genesis read/build/verify/load + one-call `start/3` kernel bring-up | | `define_registry.erl` | Meta-projection fold for `Create{Define*}` → registry | | `sandbox.erl` | `eval_pure/2,3` try/catch envelope for projection folds | -| `nx_kernel.erl` | Long-lived runtime orchestrator (state + gen_server) | +| `nx_kernel.erl` | Long-lived runtime orchestrator; per-actor bucketed state (m2 Step 1a) | | `http_server.erl` | route/1,2 + format-aware GET + POST + Accept header content negotiation | ## Genesis bundle diff --git a/next/kernel/nx_kernel.erl b/next/kernel/nx_kernel.erl index d16b7983..bc70bf5a 100644 --- a/next/kernel/nx_kernel.erl +++ b/next/kernel/nx_kernel.erl @@ -1,82 +1,232 @@ -module(nx_kernel). -behaviour(gen_server). --export([new/3, publish/2, + +%% Pure-functional API +-export([new/0, new/3, + add_actor/4, has_actor/2, actors/1, actor_count/1, + publish/2, publish/3, actor_id/1, log_state/1, log_tip/1, - key_spec/1, actor_state/1, projections/1, - next_published/1, with_projections/2]). + key_spec/1, actor_state/1, projections/1, next_published/1, + actor_log_state/2, actor_log_tip/2, + actor_key_spec/2, actor_state/2, actor_projections/2, + actor_next_published/2, actor_bucket/2, + with_projections/2, with_actor_projections/3, + next_actor_seq/1]). + +%% gen_server API -export([start_link/3, publish/1, query/0, log_tip/0, with_projections/1, stop/0]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). %% Kernel orchestrator — the long-lived runtime state held by the -%% running fed-sx instance. The HTTP layer (Step 8c-post-publish -%% follow-up) will park this in a gen_server and dispatch the POST -%% /activity request through `publish/2`. +%% running fed-sx instance. Step 1 (m2) refactor: state is now +%% per-actor bucketed so one kernel hosts any number of actors. %% -%% State shape (property list): -%% [{actor_id, A}, -%% {key_spec, KS}, % proplist: key_id / algorithm / value -%% {actor_state, AS}, % proplist: public_keys -%% {log, L}, % log:open/2 return value -%% {projections, [Name]}, % list of registered projection process names -%% {next_published, N}] % monotonic counter we feed as :published +%% New state shape (property list): +%% [{actors, [{ActorId, ActorBucket}, ...]}, +%% {next_actor_seq, NextN}] %% -%% Step 6c's stage_replay catches duplicates by `:id`; the `:id` -%% is derived from the unsigned envelope contents. Same Request + -%% same `:published` -> same CID, so the next_published counter -%% gives every publish a distinct timestamp without needing a -%% wall-clock BIF. +%% ActorBucket = [{key_spec, KS}, +%% {actor_state, AS}, +%% {log, L}, +%% {projections, [Name]}, +%% {next_published, NextSeq}] +%% +%% Legacy single-actor accessors (actor_id/1, key_spec/1, etc.) +%% continue to read from the first registered actor — keeps every +%% pre-m2 test passing through bootstrap:start/3. +%% +%% next_actor_seq is a monotonic counter handed out to add_actor for +%% future use (e.g. per-actor URL paths in Step 4). It's not yet +%% read by the rest of the kernel. + +%% ── Pure-functional API ────────────────────────────────────────── + +new() -> + [{actors, []}, {next_actor_seq, 1}]. new(ActorId, KeySpec, ActorStateProplist) -> - {ok, L0} = log:open(ActorId, base_stub()), - [{actor_id, ActorId}, - {key_spec, KeySpec}, - {actor_state, ActorStateProplist}, - {log, L0}, - {projections, []}, - {next_published, 1}]. + {ok, S} = add_actor(ActorId, KeySpec, ActorStateProplist, new()), + S. -%% publish/2 — pure state transition. Returns either: -%% {ok, Result, NewState} — log + counter advanced -%% {error, Reason, State} — state unchanged on validation halt -publish(Request, State) -> - P = field(next_published, State), - Ctx = [{actor_id, field(actor_id, State)}, - {published, P}, - {key_spec, field(key_spec, State)}, - {actor_state, field(actor_state, State)}, - {log, field(log, State)}, - {projections, field(projections, State)}], - case outbox:publish(Request, Ctx) of - {ok, Result, NewLog} -> - State1 = set(log, NewLog, State), - State2 = set(next_published, P + 1, State1), - {ok, Result, State2}; - {error, Reason, _} -> - {error, Reason, State} +add_actor(ActorId, KeySpec, AS, State) -> + Actors = field(actors, State), + case has_keyed(ActorId, Actors) of + true -> + {error, already_present}; + false -> + {ok, L0} = log:open(ActorId, base_stub()), + Bucket = [{key_spec, KeySpec}, + {actor_state, AS}, + {log, L0}, + {projections, []}, + {next_published, 1}], + Seq = field(next_actor_seq, State), + State1 = set(actors, Actors ++ [{ActorId, Bucket}], State), + State2 = set(next_actor_seq, Seq + 1, State1), + {ok, State2} end. -%% Accessors +has_actor(ActorId, State) -> + has_keyed(ActorId, field(actors, State)). -actor_id(State) -> field(actor_id, State). -key_spec(State) -> field(key_spec, State). -actor_state(State) -> field(actor_state, State). -log_state(State) -> field(log, State). -log_tip(State) -> log:tip(field(log, State)). -projections(State) -> field(projections, State). -next_published(State) -> field(next_published, State). +actors(State) -> + [Id || {Id, _Bucket} <- field(actors, State)]. + +actor_count(State) -> + length(field(actors, State)). + +next_actor_seq(State) -> + field(next_actor_seq, State). + +actor_bucket(ActorId, State) -> + find_keyed(ActorId, field(actors, State)). + +%% publish/3 — per-actor publish. +publish(ActorId, Request, State) -> + case actor_bucket(ActorId, State) of + {error, no_actor} -> + {error, no_actor, State}; + {ok, Bucket} -> + P = field(next_published, Bucket), + Ctx = [{actor_id, ActorId}, + {published, P}, + {key_spec, field(key_spec, Bucket)}, + {actor_state, field(actor_state, Bucket)}, + {log, field(log, Bucket)}, + {projections, field(projections, Bucket)}], + case outbox:publish(Request, Ctx) of + {ok, Result, NewLog} -> + B1 = set(log, NewLog, Bucket), + B2 = set(next_published, P + 1, B1), + NewState = set_bucket(ActorId, B2, State), + {ok, Result, NewState}; + {error, Reason, _} -> + {error, Reason, State} + end + end. + +%% publish/2 — legacy single-actor publish; routes to first actor. +publish(Request, State) -> + case actors(State) of + [] -> {error, no_actor, State}; + [First | _] -> publish(First, Request, State) + end. + +with_actor_projections(ActorId, Names, State) -> + case actor_bucket(ActorId, State) of + {error, no_actor} -> + {error, no_actor}; + {ok, Bucket} -> + B1 = set(projections, Names, Bucket), + {ok, set_bucket(ActorId, B1, State)} + end. -%% with_projections — return a new state with :projections replaced. with_projections(Names, State) -> - set(projections, Names, State). + case actors(State) of + [] -> State; + [First | _] -> + {ok, NewState} = with_actor_projections(First, Names, State), + NewState + end. -%% Internal +%% Per-actor accessors + +actor_log_state(ActorId, State) -> + case actor_bucket(ActorId, State) of + {ok, B} -> {ok, field(log, B)}; + {error, _} -> {error, no_actor} + end. + +actor_log_tip(ActorId, State) -> + case actor_log_state(ActorId, State) of + {ok, L} -> log:tip(L); + {error, _} -> nil + end. + +actor_key_spec(ActorId, State) -> + case actor_bucket(ActorId, State) of + {ok, B} -> {ok, field(key_spec, B)}; + {error, _} -> {error, no_actor} + end. + +actor_state(ActorId, State) when is_list(State), is_atom(ActorId) -> + case actor_bucket(ActorId, State) of + {ok, B} -> {ok, field(actor_state, B)}; + {error, _} -> {error, no_actor} + end. + +actor_projections(ActorId, State) -> + case actor_bucket(ActorId, State) of + {ok, B} -> {ok, field(projections, B)}; + {error, _} -> {error, no_actor} + end. + +actor_next_published(ActorId, State) -> + case actor_bucket(ActorId, State) of + {ok, B} -> {ok, field(next_published, B)}; + {error, _} -> {error, no_actor} + end. + +%% Legacy single-actor accessors — read from first bucket. Keeps +%% every M1 test (smoke_app_pure, bootstrap_start, http_publish, +%% nx_kernel_server, http_post_format) passing. + +actor_id(State) -> + case field(actors, State) of + [] -> nil; + [{First, _Bucket} | _] -> First + end. + +key_spec(State) -> + bucket_field(key_spec, State). + +actor_state(State) -> + bucket_field(actor_state, State). + +log_state(State) -> + bucket_field(log, State). + +log_tip(State) -> + log:tip(log_state(State)). + +projections(State) -> + case bucket_field(projections, State) of + nil -> []; + Ps -> Ps + end. + +next_published(State) -> + bucket_field(next_published, State). + +%% ── Internal helpers ────────────────────────────────────────────── -%% "base_stub" — placeholder base path for the in-memory log -%% in v1 (the in-memory log ignores the base argument). base_stub() -> <<98,97,115,101,95,115,116,117,98>>. +bucket_field(Key, State) -> + case field(actors, State) of + [] -> nil; + [{_First, Bucket} | _] -> field(Key, Bucket) + end. + +set_bucket(ActorId, NewBucket, State) -> + Actors = field(actors, State), + NewActors = set_keyed(ActorId, NewBucket, Actors), + set(actors, NewActors, State). + +set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest]; +set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)]; +set_keyed(_, _, []) -> []. + +has_keyed(_, []) -> false; +has_keyed(K, [{K, _} | _]) -> true; +has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest). + +find_keyed(_, []) -> {error, no_actor}; +find_keyed(K, [{K, V} | _]) -> {ok, V}; +find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). + field(K, [{K, V} | _]) -> V; field(K, [_ | Rest]) -> field(K, Rest); field(_, []) -> nil. @@ -91,6 +241,10 @@ set(K, V, [P | Rest]) -> [P | set(K, V, Rest)]. %% Steps 5b and 7b. Same port quirks: raw Pid return, no `?MODULE` %% macro, spawned processes don't persist across separate %% erlang-eval-ast calls — tests inline start_link with operations. +%% +%% Step 1a (m2) keeps the gen_server single-actor; multi-actor +%% gen_server calls (publish_to/2, log_tip_for/1, ...) land in +%% iteration 1b. start_link(ActorId, KeySpec, ActorStateProplist) -> Pid = gen_server:start_link(nx_kernel, diff --git a/next/tests/nx_kernel_multi.sh b/next/tests/nx_kernel_multi.sh new file mode 100755 index 00000000..eafae1c6 --- /dev/null +++ b/next/tests/nx_kernel_multi.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# next/tests/nx_kernel_multi.sh — m2 Step 1a tests. +# +# Pure-functional multi-actor bucket APIs. No gen_server. + +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 + +# Two actors share the same signing-key bytes but have different ids; +# signatures verify because each carries the matching public_keys +# entry. AliceK / BobK distinguish them visually only. +PRELUDE='AliceK = <<1,2,3,4>>, AliceKS = [{key_id,k1},{algorithm,ed25519},{value,AliceK}], AliceAS = [{public_keys,[[{id,k1},{created,0},{value,AliceK}]]}], BobK = <<5,6,7,8>>, BobKS = [{key_id,k1},{algorithm,ed25519},{value,BobK}], BobAS = [{public_keys,[[{id,k1},{created,0},{value,BobK}]]}], Req = [{type,create},{object,nil}],' + +cat > "$TMPFILE" < {error, already_present} +(epoch 16) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), case nx_kernel:add_actor(alice, AliceKS, AliceAS, S) of {error, already_present} -> ok; _ -> bad end\") :name)") + +;; add two distinct actors -> both present +(epoch 17) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), nx_kernel:actors(S2) =:= [alice, bob]\") :name)") + +;; next_actor_seq increments per add +(epoch 18) +(eval "(erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), nx_kernel:next_actor_seq(S2)\")") + +;; publish/3 to known actor returns {ok, _, NewState} +(epoch 19) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), case nx_kernel:publish(alice, Req, S1) of {ok, _, _} -> ok; _ -> bad end\") :name)") + +;; publish/3 advances only the named actor's log +(epoch 20) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, _, S3} = nx_kernel:publish(alice, Req, S2), AliceTip = nx_kernel:actor_log_tip(alice, S3), BobTip = nx_kernel:actor_log_tip(bob, S3), {AliceTip, BobTip} =:= {1, 0}\") :name)") + +;; publish/3 to unknown actor -> {error, no_actor, State} +(epoch 21) +(eval "(get (erlang-eval-ast \"${PRELUDE} S = nx_kernel:new(), case nx_kernel:publish(ghost, Req, S) of {error, no_actor, _} -> ok; _ -> bad end\") :name)") + +;; Two actors maintain independent next_published counters +(epoch 22) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, _, S3} = nx_kernel:publish(alice, Req, S2), {ok, _, S4} = nx_kernel:publish(alice, Req, S3), {ok, _, S5} = nx_kernel:publish(bob, Req, S4), {ok, AliceN} = nx_kernel:actor_next_published(alice, S5), {ok, BobN} = nx_kernel:actor_next_published(bob, S5), {AliceN, BobN} =:= {3, 2}\") :name)") + +;; actor_state/2 returns per-actor AS +(epoch 23) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, ASa} = nx_kernel:actor_state(alice, S2), {ok, ASb} = nx_kernel:actor_state(bob, S2), {ASa, ASb} =:= {AliceAS, BobAS}\") :name)") + +;; with_actor_projections sets per-actor projection list +(epoch 24) +(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, S3} = nx_kernel:with_actor_projections(alice, [px], S2), {ok, AliceP} = nx_kernel:actor_projections(alice, S3), {ok, BobP} = nx_kernel:actor_projections(bob, S3), {AliceP, BobP} =:= {[px], []}\") :name)") + +;; Legacy new/3 + publish/2 still route to the single actor +(epoch 25) +(eval "(get (erlang-eval-ast \"${PRELUDE} S = nx_kernel:new(alice, AliceKS, AliceAS), {ok, _, S1} = nx_kernel:publish(Req, S), nx_kernel:log_tip(S1) =:= 1 andalso nx_kernel:actor_id(S1) =:= alice\") :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 6 "nx_kernel module loaded" "nx_kernel" +check 10 "new/0 -> 0 actors" "0" +check 11 "new/0 -> next_actor_seq = 1" "1" +check 12 "new/0 actor_id = nil" "true" +check 13 "add_actor returns {ok, NewState}" "ok" +check 14 "has_actor true after add" "true" +check 15 "actors/1 lists added actor" "true" +check 16 "duplicate add -> already_present" "ok" +check 17 "two distinct actors both present" "true" +check 18 "next_actor_seq increments" "3" +check 19 "publish/3 returns {ok, _, S}" "ok" +check 20 "publish/3 isolates per actor" "true" +check 21 "publish/3 unknown -> no_actor" "ok" +check 22 "independent next_published seqs" "true" +check 23 "actor_state/2 per-actor" "true" +check 24 "with_actor_projections per-actor" "true" +check 25 "legacy new/3 + publish/2 routes" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/nx_kernel_multi.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 45363163..ad917443 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -115,36 +115,31 @@ actors. **Deliverables:** -```erlang -%% nx_kernel state shape becomes: -%% [{actors, [{ActorId, ActorBucket}, ...]}, -%% {next_actor_seq, NextN}] -%% -%% ActorBucket = [{key_spec, KS}, {actor_state, AS}, -%% {log, LogState}, {projections, [Name]}, -%% {next_published, NextSeq}] - --export([new/0, add_actor/4, has_actor/2, - publish/2, publish/3, %% /2 = first actor only - actor_log_tip/2, actor_state/2, ...]). - -new() -> [{actors, []}, {next_actor_seq, 1}]. -add_actor(ActorId, KeySpec, AS, State) -> {ok, NewState}. -publish(ActorId, Request, State) -> ... %% per-actor -``` - -`bootstrap:start/3` continues to work — it adds one actor named `alice` -to a fresh kernel — preserving every M1 test that uses the -single-actor entry point. - -**Tests:** - -- New kernel has no actors. -- add_actor + has_actor round-trip. -- Two actors maintain independent logs + sequences. -- publish/3 advances only the named actor's bucket. -- Concurrent gen_server-mediated publishes for different actors don't - serialise. +- [x] **1a** — Pure-functional bucket APIs. State shape becomes + `[{actors, [{ActorId, ActorBucket}, ...]}, {next_actor_seq, N}]` + with `ActorBucket = [{key_spec, KS}, {actor_state, AS}, {log, L}, + {projections, [Name]}, {next_published, N}]`. New exports: `new/0`, + `add_actor/4`, `has_actor/2`, `actors/1`, `actor_count/1`, + `next_actor_seq/1`, `actor_bucket/2`, `publish/3`, per-actor + accessors (`actor_log_state/2`, `actor_log_tip/2`, `actor_key_spec/2`, + `actor_state/2`, `actor_projections/2`, `actor_next_published/2`), + `with_actor_projections/3`. Legacy single-actor accessors + (`actor_id/1`, `key_spec/1`, `actor_state/1`, `log_state/1`, + `log_tip/1`, `projections/1`, `next_published/1`, + `with_projections/2`, legacy `publish/2`) continue to read from the + first bucket — every M1 test passes via `bootstrap:start/3` → + `new/3` → first-bucket lookup. `lists:keymember`/`keyfind` not in + the substrate; local `has_keyed`/`find_keyed`/`set_keyed`/ + `set_bucket` helpers handle the keyed-list ops. + `next/tests/nx_kernel_multi.sh` 17/17. +- [ ] **1b** — Multi-actor gen_server. `start_link/3` still works as + the single-actor entry; add `add_actor/3` (gen_server call, + bumps bucket), `publish_to/2(ActorId, Request)`, `log_tip_for/1`, + `actors/0`, `state_for/1`, `with_projections_for/2`. Existing + `publish/1`/`log_tip/0`/etc route through bucket-0 unchanged. + Concurrent publishes to distinct actors don't serialise across the + mailbox (multiple casts queued before each is processed). New tests + extend `nx_kernel_multi.sh` with 6-8 gen_server-mediated cases. **Acceptance:** `bash next/tests/nx_kernel_multi.sh` passes 12+ cases. @@ -650,3 +645,48 @@ Things still under-specified; resolve as work begins. via /inbox, does A keep B's signed envelope verbatim (for re-broadcast on Announce), or does A re-construct + re-sign with A's own key? AP-canon: keep verbatim. Confirm at Step 5. + +--- + +## Blockers + +Pre-existing regressions inherited from the M1 closeout. Out of m2 +scope (substrate, not `next/**`), tracked here so iteration can +proceed. + +1. **`next/tests/http_server_tcp.sh` 0/5** — pre-existing regression + introduced by `78eae9ef` (`fed-sx-m1: 8b-bridge cleanup`). + `lib/erlang/runtime.sx:1593` still references `er-http-resp-to-sx` + and `er-http-req-of-sx` in `er-bif-http-listen`'s sx-handler body, + but the cleanup commit removed both helpers without rewriting the + BIF. Listener binds (TCP socket accepts), but every request handler + crashes on first call to the undefined helpers — curl gets 000 / + empty body. Fix needs to rewrite the sx-handler body around the + live `er-request-dict-to-proplist` / `er-proplist-to-dict` + helpers (which the cleanup commit's message claimed are already + in use, but which the BIF body never picked up). Substrate work, + belongs on `loops/erlang`. m2 work continues against the in-process + HTTP layer (`http_marshal.sh` 10/10, `http_publish_fold.sh` 10/10) + until resolved. Confirmed pre-existing by stashing 1a's changes and + re-running on the unmodified m1 closeout HEAD. + +--- + +## Progress log + +Newest first. + +- **2026-06-06** — Step 1a: per-actor bucket refactor of `nx_kernel`. + State shape now `[{actors, [{Id, Bucket}, …]}, {next_actor_seq, N}]`; + added pure-functional multi-actor APIs (`new/0`, `add_actor/4`, + `has_actor/2`, `actors/1`, `publish/3`, per-actor accessors, + `with_actor_projections/3`). Legacy single-actor accessors + preserved as bucket-0 lookups so every M1 test continues to + pass via `bootstrap:start/3` → `new/3` → first-bucket read. + Local `has_keyed`/`find_keyed`/`set_keyed`/`set_bucket` helpers + cover the keyed-list ops since `lists:keymember`/`keyfind` aren't + registered in this substrate. New test suite + `next/tests/nx_kernel_multi.sh` 17/17; all M1 nx_kernel-adjacent + suites green (`bootstrap_start`, `nx_kernel_server`, `http_publish`, + `smoke_app_pure`, `http_post_format`, `http_publish_fold`, + `http_marshal`). Erlang conformance 761/761 preserved.