From bcfbd9a528649e2be66098e7bb4363f568f7d2f5 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 11:53:14 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m2:=20Step=202b=20=E2=80=94=20actor=5Fst?= =?UTF-8?q?ate=20projection=20fold=20+=2019=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next/kernel/actor_state.erl mirrors define_registry's structure: a 2-arity fold_fn that plugs into projection:start_link/3, an Erlang-fun stand-in for the genesis actor-state.sx projection body. State shape: [{ActorId, Profile}, ...] Profile is a property list with :type, :name, :preferredUsername, :summary, :icon, :public_keys, :moved_to, :created. Maps #{} aren't registered in this substrate, so this matches the kernel bucket / registry shape convention. Folding rules per design §9.1-§9.4: - Create{Person|Service|Group}: register profile, capturing object fields + :published seq as :created. Duplicate Create no-overwrite. - Update{Person|Service|Group, patch}: deep-merge :patch into profile last-write-wins per key. - Move: record :moved_to. Other activity types and non-actor object Creates pass through. Local find_keyed/has_keyed/set_keyed helpers (same gap as Step 1a: no lists:keyfind/keymember in this substrate). 19/19 in next/tests/actor_state_pure.sh covering: - new/0/has/2/lookup/2/actors/1 base cases - Create for Person/Service/Group all three actor types - Profile field capture (name, preferredUsername, public_keys, created) - Duplicate Create no-overwrite - Two independent actors - Update field merge + per-key last-write-wins - Update for unknown actor pass-through - Move :moved_to - Non-actor Creates pass through - Activities without :actor pass through - fold_fn/0 returns is_function(F, 2) Conformance 761/761. Step-2-adjacent no-regression gate 106/106 across 6 suites (define_registry_pure, projection_pure, projection_server, nx_kernel_multi, bootstrap_start, smoke_app_pure). --- next/kernel/actor_state.erl | 178 +++++++++++++++++++++++++++++++++ next/tests/actor_state_pure.sh | 163 ++++++++++++++++++++++++++++++ plans/fed-sx-milestone-2.md | 41 ++++++-- 3 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 next/kernel/actor_state.erl create mode 100755 next/tests/actor_state_pure.sh diff --git a/next/kernel/actor_state.erl b/next/kernel/actor_state.erl new file mode 100644 index 00000000..1175caeb --- /dev/null +++ b/next/kernel/actor_state.erl @@ -0,0 +1,178 @@ +-module(actor_state). +-export([fold/2, fold_fn/0, new/0, lookup/2, has/2, actors/1, + profile_type/1, profile_name/1, profile_field/2]). + +%% Actor-state projection fold — Erlang-fun stand-in for the +%% genesis `actor-state.sx` projection body. Tracks per-actor +%% profiles, key-history, and Move pointers per design §9.1-§9.4. +%% +%% State shape: +%% [{ActorId, Profile}, ...] +%% +%% Profile = [{type, person|service|group}, +%% {name, Bin}, +%% {preferredUsername, Bin}, +%% {summary, Bin}, +%% {icon, Bin}, +%% {public_keys, [Key]}, +%% {moved_to, ActorIdOrUrl}, +%% {created, N}] +%% +%% Bridge note: the SX-source eval bridge would replace this fold +%% body once available (same gap as Step 5d-pure / Step 6c-schema-pure). +%% define_registry.erl is the structural twin. +%% +%% lists:keyfind/keymember aren't in this substrate (Step 1a noted +%% same gap), so local `find_keyed`/`has_keyed`/`set_keyed` helpers +%% handle the keyed-list ops. + +new() -> []. + +actors(State) -> [Id || {Id, _Profile} <- State]. + +has(ActorId, State) -> has_keyed(ActorId, State). + +lookup(ActorId, State) -> + case find_keyed(ActorId, State) of + {ok, Profile} -> {ok, Profile}; + {error, _} -> not_found + end. + +%% ── Fold dispatch ─────────────────────────────────────────────── + +fold(Activity, State) -> + case envelope:get_field(type, Activity) of + {ok, create} -> fold_create(Activity, State); + {ok, update} -> fold_update(Activity, State); + {ok, move} -> fold_move(Activity, State); + _ -> State + end. + +fold_create(Activity, State) -> + case envelope:get_field(object, Activity) of + {ok, Obj} -> + case envelope:get_field(type, Obj) of + {ok, ObjType} -> + case is_actor_type(ObjType) of + true -> register_actor(Activity, Obj, ObjType, State); + false -> State + end; + _ -> State + end; + _ -> State + end. + +register_actor(Activity, Obj, ObjType, State) -> + case envelope:get_field(actor, Activity) of + {ok, ActorId} -> + case has_keyed(ActorId, State) of + true -> + State; + false -> + Created = published_seq(Activity), + Profile = build_profile(ObjType, Obj, Created), + State ++ [{ActorId, Profile}] + end; + _ -> State + end. + +fold_update(Activity, State) -> + case envelope:get_field(actor, Activity) of + {ok, ActorId} -> + case find_keyed(ActorId, State) of + {ok, Profile} -> + case envelope:get_field(patch, Activity) of + {ok, Patch} -> + NewProfile = merge_patch(Profile, Patch), + set_keyed(ActorId, NewProfile, State); + _ -> State + end; + _ -> State + end; + _ -> State + end. + +fold_move(Activity, State) -> + case envelope:get_field(actor, Activity) of + {ok, ActorId} -> + case find_keyed(ActorId, State) of + {ok, Profile} -> + case envelope:get_field(moved_to, Activity) of + {ok, Target} -> + NewProfile = set_keyed(moved_to, Target, Profile), + set_keyed(ActorId, NewProfile, State); + _ -> State + end; + _ -> State + end; + _ -> State + end. + +%% ── Profile assembly ──────────────────────────────────────────── + +build_profile(ObjType, Obj, Created) -> + Base = [{type, ObjType}, {created, Created}], + Fields = [name, preferredUsername, summary, icon, public_keys], + Base ++ collect_fields(Fields, Obj). + +collect_fields([], _) -> []; +collect_fields([F | Rest], Obj) -> + case envelope:get_field(F, Obj) of + {ok, V} -> [{F, V} | collect_fields(Rest, Obj)]; + _ -> collect_fields(Rest, Obj) + end. + +merge_patch(Profile, []) -> Profile; +merge_patch(Profile, [{K, V} | Rest]) -> + merge_patch(set_keyed(K, V, Profile), Rest); +merge_patch(Profile, _) -> Profile. + +published_seq(Activity) -> + case envelope:get_field(published, Activity) of + {ok, P} -> P; + _ -> 0 + end. + +is_actor_type(person) -> true; +is_actor_type(service) -> true; +is_actor_type(group) -> true; +is_actor_type(_) -> false. + +%% ── Profile accessors ─────────────────────────────────────────── + +profile_type(Profile) -> + case find_keyed(type, Profile) of + {ok, T} -> T; + _ -> nil + end. + +profile_name(Profile) -> + case find_keyed(name, Profile) of + {ok, N} -> N; + _ -> nil + end. + +profile_field(F, Profile) -> + case find_keyed(F, Profile) of + {ok, V} -> {ok, V}; + _ -> not_found + end. + +%% ── Projection integration ────────────────────────────────────── + +fold_fn() -> + fun (Activity, State) -> fold(Activity, State) end. + +%% ── Internal ──────────────────────────────────────────────────── + +has_keyed(_, []) -> false; +has_keyed(K, [{K, _} | _]) -> true; +has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest). + +find_keyed(_, []) -> {error, not_found}; +find_keyed(K, [{K, V} | _]) -> {ok, V}; +find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). + +set_keyed(K, V, []) -> [{K, V}]; +set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest]; +set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)]. diff --git a/next/tests/actor_state_pure.sh b/next/tests/actor_state_pure.sh new file mode 100755 index 00000000..e0100a1b --- /dev/null +++ b/next/tests/actor_state_pure.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# next/tests/actor_state_pure.sh — m2 Step 2b test. +# +# Exercises the Erlang-fun stand-in for the actor-state projection +# fold. Activities flow: +# Create{Person|Service|Group} -> profile registered +# Update{Person|Service|Group, patch} -> patch deep-merged +# Move -> :moved_to recorded +# Non-actor object Creates pass through. + +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 + +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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)") +(epoch 3) +(eval "(get (erlang-load-module (file-read \"next/kernel/actor_state.erl\")) :name)") + +;; new/0 returns [] +(epoch 10) +(eval "(get (erlang-eval-ast \"actor_state:new() =:= []\") :name)") + +;; has/2 false on empty +(epoch 11) +(eval "(get (erlang-eval-ast \"actor_state:has(alice, actor_state:new()) =:= false\") :name)") + +;; lookup/2 not_found on empty +(epoch 12) +(eval "(get (erlang-eval-ast \"actor_state:lookup(alice, actor_state:new()) =:= not_found\") :name)") + +;; actors/1 returns [] on empty +(epoch 13) +(eval "(get (erlang-eval-ast \"actor_state:actors(actor_state:new()) =:= []\") :name)") + +;; Create{Person} registers profile +(epoch 14) +(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, alice_name}, {preferredUsername, alice_local}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), actor_state:has(alice, S)\") :name)") + +;; Profile carries :type, :name, :preferredUsername, :created +(epoch 15) +(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, alice_name}, {preferredUsername, alice_local}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 7}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(alice, S), {actor_state:profile_type(P), actor_state:profile_name(P), actor_state:profile_field(preferredUsername, P), actor_state:profile_field(created, P)} =:= {person, alice_name, {ok, alice_local}, {ok, 7}}\") :name)") + +;; Create{Service} also registers +(epoch 16) +(eval "(get (erlang-eval-ast \"Obj = [{type, service}, {name, feedbot}], Act = [{actor, feed1}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(feed1, S), actor_state:profile_type(P) =:= service\") :name)") + +;; Create{Group} also registers +(epoch 17) +(eval "(get (erlang-eval-ast \"Obj = [{type, group}, {name, working_group}], Act = [{actor, wg1}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(wg1, S), actor_state:profile_type(P) =:= group\") :name)") + +;; Create{Note} is pass-through (non-actor object) +(epoch 18) +(eval "(get (erlang-eval-ast \"Obj = [{type, note}, {content, hi}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], actor_state:fold(Act, actor_state:new()) =:= []\") :name)") + +;; Duplicate Create doesn't overwrite an existing profile +(epoch 19) +(eval "(get (erlang-eval-ast \"O1 = [{type, person}, {name, alice_v1}], O2 = [{type, person}, {name, alice_v2}], A1 = [{actor, alice}, {type, create}, {object, O1}, {published, 1}], A2 = [{actor, alice}, {type, create}, {object, O2}, {published, 2}], S1 = actor_state:fold(A1, actor_state:new()), S2 = actor_state:fold(A2, S1), {ok, P} = actor_state:lookup(alice, S2), actor_state:profile_name(P) =:= alice_v1\") :name)") + +;; Two distinct actors live side by side +(epoch 20) +(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], SO = [{type, service}, {name, bobbot_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, bobbot}, {type, create}, {object, SO}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), actor_state:actors(S) =:= [alice, bobbot]\") :name)") + +;; Update merges patch +(epoch 21) +(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, update}, {patch, [{summary, new_bio}]}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(summary, P) =:= {ok, new_bio}\") :name)") + +;; Update overwrites individual fields (last-write-wins per key) +(epoch 22) +(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_v1}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, update}, {patch, [{name, alice_v2}]}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_name(P) =:= alice_v2\") :name)") + +;; Update for unknown actor is pass-through +(epoch 23) +(eval "(get (erlang-eval-ast \"A = [{actor, ghost}, {type, update}, {patch, [{summary, x}]}, {published, 1}], actor_state:fold(A, actor_state:new()) =:= []\") :name)") + +;; Move records :moved_to +(epoch 24) +(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, move}, {moved_to, new_alice}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(moved_to, P) =:= {ok, new_alice}\") :name)") + +;; fold_fn/0 is a 2-arity Erlang fun usable by projection:start_link +(epoch 25) +(eval "(get (erlang-eval-ast \"F = actor_state:fold_fn(), is_function(F, 2)\") :name)") + +;; fold ignores activities with no :actor field +(epoch 26) +(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, x}], Act = [{type, create}, {object, Obj}, {published, 1}], actor_state:fold(Act, actor_state:new()) =:= []\") :name)") + +;; public_keys field is captured at Create time +(epoch 27) +(eval "(get (erlang-eval-ast \"Keys = [[{id, k1}, {value, <<1,2,3,4>>}]], Obj = [{type, person}, {name, alice_n}, {public_keys, Keys}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(public_keys, P) =:= {ok, Keys}\") :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 3 "actor_state module loaded" "actor_state" +check 10 "new/0 -> []" "true" +check 11 "has/2 false on empty" "true" +check 12 "lookup/2 not_found on empty" "true" +check 13 "actors/1 [] on empty" "true" +check 14 "Create{Person} registers actor" "true" +check 15 "Profile carries type/name/created" "true" +check 16 "Create{Service} registers actor" "true" +check 17 "Create{Group} registers actor" "true" +check 18 "Create{Note} pass-through" "true" +check 19 "Duplicate Create no-overwrite" "true" +check 20 "Two actors side by side" "true" +check 21 "Update merges new fields" "true" +check 22 "Update last-write-wins per key" "true" +check 23 "Update unknown actor pass-through" "true" +check 24 "Move records :moved_to" "true" +check 25 "fold_fn/0 is fun/2" "true" +check 26 "Activity sans :actor pass-through" "true" +check 27 "public_keys captured at Create" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/actor_state_pure.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 f799c1e5..3d6e36ac 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -169,16 +169,25 @@ publicKey rotation history, profile fields, follower counts, etc. `bootstrap_load.sh` 15/15, `bootstrap_populate.sh` 14/14, `bootstrap_start.sh` 10/10). `bootstrap_build.sh` 12/12 picks up the new bundle CID dynamically. -- [ ] **2b** — Actor-state projection fold (Erlang-fun stand-in, - mirrors Step 5d-pure's `define_registry`): - - On `Create{Person|Service|Group}`: register the actor's profile - in `{ActorId => #{type, name, preferredUsername, summary, icon, - public_keys, created}}`. - - On `Update{Person|Service|Group, patch}`: deep-merge the patch. - - On `Move`: record `:movedTo` pointer. - - `next/kernel/actor_state.erl` with `fold_fn/0` plugging into - `projection:start_link/3`. Pure-functional + gen_server-bridged - tests as a single `actor_state_pure.sh`. +- [x] **2b** — Actor-state projection fold (Erlang-fun stand-in, + mirrors Step 5d-pure's `define_registry`). `next/kernel/actor_state.erl` + with state shape `[{ActorId, Profile}, ...]` where `Profile` is a + proplist with `:type / :name / :preferredUsername / :summary / + :icon / :public_keys / :moved_to / :created`. Maps `#{}` aren't + registered in the substrate, so the profile is a property list + (same shape choice as the kernel's bucket / registry state). + Folding rules: + - `Create{Person|Service|Group}` (from a known `:actor`): + captures profile fields + `:created` (=`:published` seq). + Duplicate Creates are no-overwrite. + - `Update{Person|Service|Group, patch}`: merges `:patch` into the + profile last-write-wins per key. + - `Move`: records `:moved_to` on the profile. + Other activity types and non-actor object Creates pass through. + `fold_fn/0` plugs into `projection:start_link/3`. Local + `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 @@ -691,6 +700,18 @@ proceed. Newest first. +- **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 / + :preferredUsername / :summary / :icon / :public_keys / :moved_to / + :created`. Create captures fields and `:published` as `:created`; + duplicate Create is no-overwrite; non-actor Creates and `:actor`- + less envelopes pass through. Update last-write-wins per patch key. + Move records `:moved_to`. `fold_fn/0` is a 2-arity Erlang fun for + `projection:start_link/3` (structural twin of `define_registry`). + `next/tests/actor_state_pure.sh` 19/19. Conformance 761/761. + Step-2-adjacent no-regression gate 106/106 across 6 suites. + - **2026-06-06** — Step 2a: genesis Person/Service/Group object- types. Three new SX files in `next/genesis/object-types/` with the same shape as `note.sx` / `sx-artifact.sx` (`:name`, `:doc`,