diff --git a/next/kernel/nx_kernel.erl b/next/kernel/nx_kernel.erl index e73002b2..8634b198 100644 --- a/next/kernel/nx_kernel.erl +++ b/next/kernel/nx_kernel.erl @@ -5,6 +5,7 @@ -export([new/0, new/3, add_actor/4, has_actor/2, actors/1, actor_count/1, publish/2, publish/3, + bootstrap_actor/4, actor_id/1, log_state/1, log_tip/1, key_spec/1, actor_state/1, projections/1, next_published/1, actor_log_state/2, actor_log_tip/2, @@ -18,7 +19,8 @@ with_projections/1, stop/0, add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1, bucket_for/1, - with_projections_for/2]). + with_projections_for/2, + bootstrap_actor/3]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). %% Kernel orchestrator — the long-lived runtime state held by the @@ -116,6 +118,40 @@ publish(Request, State) -> [First | _] -> publish(First, Request, State) end. +%% bootstrap_actor/4 — register an actor bucket and immediately +%% publish a Create{Person|Service|Group} as that actor's first +%% activity. Profile carries the object fields plus :public_keys. +%% Returns {ok, Result, NewState} where Result has the published +%% Create's CID, or {error, Reason, State} on validation halt. + +bootstrap_actor(ActorId, Profile, KeySpec, State) -> + PublicKeys = case field(public_keys, Profile) of + nil -> []; + KS -> KS + end, + AS = [{public_keys, PublicKeys}], + case add_actor(ActorId, KeySpec, AS, State) of + {ok, State1} -> + ActorType = case field(type, Profile) of + nil -> person; + T -> T + end, + Object = [{type, ActorType}] ++ collect_profile_fields( + [name, preferredUsername, summary, icon, public_keys], + Profile), + Request = [{type, create}, {object, Object}], + publish(ActorId, Request, State1); + {error, Reason} -> + {error, Reason, State} + end. + +collect_profile_fields([], _) -> []; +collect_profile_fields([F | Rest], Profile) -> + case field(F, Profile) of + nil -> collect_profile_fields(Rest, Profile); + V -> [{F, V} | collect_profile_fields(Rest, Profile)] + end. + with_actor_projections(ActorId, Names, State) -> case actor_bucket(ActorId, State) of {error, no_actor} -> @@ -297,6 +333,9 @@ bucket_for(ActorId) -> with_projections_for(ActorId, Names) -> gen_server:call(nx_kernel, {set_projections_for, ActorId, Names}). +bootstrap_actor(ActorId, Profile, KeySpec) -> + gen_server:call(nx_kernel, {bootstrap_actor, ActorId, Profile, KeySpec}). + %% gen_server callbacks init([ActorId, KeySpec, AS]) -> @@ -337,6 +376,11 @@ handle_call({set_projections_for, ActorId, Names}, _From, State) -> case with_actor_projections(ActorId, Names, State) of {ok, NewState} -> {reply, ok, NewState}; {error, Reason} -> {reply, {error, Reason}, State} + end; +handle_call({bootstrap_actor, ActorId, Profile, KeySpec}, _From, State) -> + case bootstrap_actor(ActorId, Profile, KeySpec, State) of + {ok, Result, NewState} -> {reply, {ok, Result}, NewState}; + {error, Reason, SameState} -> {reply, {error, Reason}, SameState} end. handle_cast(_, S) -> {noreply, S}. diff --git a/next/tests/actor_lifecycle.sh b/next/tests/actor_lifecycle.sh new file mode 100755 index 00000000..dd14d6fc --- /dev/null +++ b/next/tests/actor_lifecycle.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# next/tests/actor_lifecycle.sh — m2 Step 2c end-to-end test. +# +# Ties Step 2a artefacts (genesis Person/Service/Group SX files), +# Step 2b projection (actor_state.erl), and Step 2c bootstrap +# (nx_kernel:bootstrap_actor/4) together. Profiles bootstrap as +# Create{Person|Service|Group} activities; the actor_state projection +# folds them into the per-actor profile registry. + +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 signing-key bytes (each in its own AS). The +# profile's :public_keys list is what gets wrapped in the Create +# object; the kernel-side AS proplist (built by bootstrap_actor/4 +# from :public_keys) is what envelope:verify_signature reads. +ALICE_KM='AliceK = <<1,2,3,4>>, AliceKey = [{id, k1}, {created, 0}, {value, AliceK}], AlicePks = [AliceKey], AliceKS = [{key_id, k1}, {algorithm, ed25519}, {value, AliceK}],' +BOB_KM='BobK = <<5,6,7,8>>, BobKey = [{id, k1}, {created, 0}, {value, BobK}], BobPks = [BobKey], BobKS = [{key_id, k1}, {algorithm, ed25519}, {value, BobK}],' +ALICE_PROFILE='AliceProfile = [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}],' +BOB_PROFILE='BobProfile = [{type, service}, {name, bobbot_n}, {preferredUsername, bobbot_local}, {public_keys, BobPks}],' + +# actor_state projection wiring — fold_fn from actor_state:fold_fn/0, +# initial state = actor_state:new(). +PROJ_SETUP='projection:start_link(actors, actor_state:new(), actor_state:fold_fn()),' + +cat > "$TMPFILE" < ok; _ -> bad end\") :name)") + +;; Pure: after bootstrap, log_tip = 1, has_actor true +(epoch 11) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), nx_kernel:has_actor(alice, S) andalso nx_kernel:actor_log_tip(alice, S) =:= 1\") :name)") + +;; Pure: log entry is a Create with object's type = person +(epoch 12) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, create} = envelope:get_field(type, E), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)") + +;; Pure: bootstrap into existing kernel with another actor +(epoch 13) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), nx_kernel:actors(S2) =:= [alice, bobbot]\") :name)") + +;; Pure: two actors have independent log_tips +(epoch 14) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), {nx_kernel:actor_log_tip(alice, S2), nx_kernel:actor_log_tip(bobbot, S2)} =:= {1, 1}\") :name)") + +;; Pure: duplicate bootstrap_actor returns already_present +(epoch 15) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), case nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, S1) of {error, already_present, _} -> ok; _ -> bad end\") :name)") + +;; gen_server: bootstrap_actor/3 publishes + actor_state projection captures profile +(epoch 16) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(seed, [actors]), {ok, _} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS), nx_kernel:has_actor(seed, nx_kernel:query()) andalso nx_kernel:has_actor(alice, nx_kernel:query())\") :name)") + +;; gen_server: actor_state projection captures the bootstrapped Person profile +(epoch 17) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(alice_pre, [actors]), nx_kernel:add_actor(alice_pre, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice_pre, [actors]), {ok, _} = nx_kernel:publish_to(alice_pre, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice_pre, projection:query(actors)), actor_state:profile_type(Profile) =:= person andalso actor_state:profile_name(Profile) =:= alice_n\") :name)") + +;; gen_server: Service profile lands as service in actor_state +(epoch 18) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, BobKS, [{public_keys, BobPks}]), ${PROJ_SETUP} nx_kernel:add_actor(bobbot, BobKS, [{public_keys, BobPks}]), nx_kernel:with_projections_for(bobbot, [actors]), {ok, _} = nx_kernel:publish_to(bobbot, [{type, create}, {object, [{type, service}, {name, bobbot_n}, {public_keys, BobPks}]}]), {ok, Profile} = actor_state:lookup(bobbot, projection:query(actors)), actor_state:profile_type(Profile) =:= service\") :name)") + +;; gen_server: Group profile lands as group in actor_state +(epoch 19) +(eval "(get (erlang-eval-ast \"${ALICE_KM} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(wg1, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(wg1, [actors]), {ok, _} = nx_kernel:publish_to(wg1, [{type, create}, {object, [{type, group}, {name, working_group_n}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(wg1, projection:query(actors)), actor_state:profile_type(Profile) =:= group\") :name)") + +;; Sanity: profile captures :preferredUsername + :public_keys from the Create object +(epoch 20) +(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(alice, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice, [actors]), {ok, _} = nx_kernel:publish_to(alice, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice, projection:query(actors)), actor_state:profile_field(preferredUsername, Profile) =:= {ok, alice_local} andalso actor_state:profile_field(public_keys, Profile) =:= {ok, AlicePks}\") :name)") + +;; Pure: profile defaults to person when :type missing +(epoch 21) +(eval "(get (erlang-eval-ast \"${ALICE_KM} TypelessProfile = [{name, alice_n}, {public_keys, AlicePks}], {ok, _, S} = nx_kernel:bootstrap_actor(alice, TypelessProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)") + +;; Pure: empty profile :public_keys defaults to [] +(epoch 22) +(eval "(get (erlang-eval-ast \"${ALICE_KM} EmptyProfile = [{type, person}, {name, alice_n}], case nx_kernel:bootstrap_actor(alice, EmptyProfile, AliceKS, nx_kernel:new()) of {ok, _, _} -> ok; {error, _, _} -> ok end\") :name)") +EPOCHS + +OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 2 "gen_server loaded" "gen_server" +check 9 "nx_kernel loaded" "nx_kernel" +check 10 "bootstrap_actor/4 -> {ok, _, _}" "ok" +check 11 "bootstrap_actor advances log_tip" "true" +check 12 "log entry is Create{Person}" "true" +check 13 "two actors live in one kernel" "true" +check 14 "independent log_tips after boot" "true" +check 15 "duplicate boot -> already_present" "ok" +check 16 "gen_server bootstrap_actor/3" "true" +check 17 "actor_state captures Person" "true" +check 18 "actor_state captures Service" "true" +check 19 "actor_state captures Group" "true" +check 20 "profile carries preferredUsername" "true" +check 21 "typeless profile defaults Person" "true" +check 22 "empty public_keys handled" "ok" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/actor_lifecycle.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 3d6e36ac..2d5ca0b6 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -188,11 +188,17 @@ publicKey rotation history, profile fields, follower counts, etc. `find_keyed/has_keyed/set_keyed` helpers (same gap as 1a — no `lists:keyfind`/`keymember` in the substrate). 19 cases in `actor_state_pure.sh`. -- [ ] **2c** — `nx_kernel:bootstrap_actor/4(ActorId, Profile, - KeySpec, State)` — publishes `Create{Person{...}}` as the actor's - first activity, exercising the full pipeline. Integration test - in `actor_lifecycle.sh` ties 2a artefacts (SX files), 2b - projection, and 2c bootstrap together. +- [x] **2c** — `nx_kernel:bootstrap_actor/4(ActorId, Profile, + KeySpec, State)` — adds an actor bucket and publishes + `Create{Person|Service|Group}` as the bucket's first activity in + one call. Profile carries `:type` (defaults to `person`), `:name`, + `:preferredUsername`, `:summary`, `:icon`, `:public_keys`; the + function builds the Create's `:object` from the profile and the + kernel-side AS from `:public_keys`. gen_server variant + `bootstrap_actor/3` for live-kernel use; integration test in + `actor_lifecycle.sh` ties 2a artefacts, 2b projection, and 2c + bootstrap together end-to-end (pure + gen_server + projection + capture for all three actor types). 15/15. **Acceptance:** `bash next/tests/actor_lifecycle.sh` passes 10+ cases. @@ -700,6 +706,21 @@ proceed. Newest first. +- **2026-06-06** — Step 2c (closes Step 2): `bootstrap_actor/4` + + end-to-end `actor_lifecycle.sh`. New pure-functional export + `nx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State)` + adds an actor bucket via `add_actor/4`, derives the kernel AS + proplist from `Profile`'s `:public_keys`, builds a Create + envelope wrapping the profile's `:type` (defaults `person`) + + field set, and calls `publish/3`. gen_server variant + `bootstrap_actor/3` for live-kernel use plus a corresponding + `handle_call` branch. `actor_lifecycle.sh` 15/15 covers pure + bootstrap (log_tip advances, Create-shape, dup detection), + two-actor independence, gen_server bootstrap, and + `actor_state` projection capture for Person + Service + Group. + Step 2 fully closed (2a + 2b + 2c). Conformance 761/761. + 146/146 across 10 Step-2-adjacent suites. + - **2026-06-06** — Step 2b: actor-state projection Erlang module. New `next/kernel/actor_state.erl` with `fold/2` over Create / Update / Move activities. Profile is a property list of `:type / :name /