fed-sx-m2: Step 2b — actor_state projection fold + 19 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s

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).
This commit is contained in:
2026-06-06 11:53:14 +00:00
parent 0c44a10c8f
commit bcfbd9a528
3 changed files with 372 additions and 10 deletions

178
next/kernel/actor_state.erl Normal file
View File

@@ -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)].

163
next/tests/actor_state_pure.sh Executable file
View File

@@ -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="<no output for epoch $epoch>"
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 ]

View File

@@ -169,16 +169,25 @@ publicKey rotation history, profile fields, follower counts, etc.
`bootstrap_load.sh` 15/15, `bootstrap_populate.sh` 14/14, `bootstrap_load.sh` 15/15, `bootstrap_populate.sh` 14/14,
`bootstrap_start.sh` 10/10). `bootstrap_build.sh` 12/12 picks `bootstrap_start.sh` 10/10). `bootstrap_build.sh` 12/12 picks
up the new bundle CID dynamically. up the new bundle CID dynamically.
- [ ] **2b** — Actor-state projection fold (Erlang-fun stand-in, - [x] **2b** — Actor-state projection fold (Erlang-fun stand-in,
mirrors Step 5d-pure's `define_registry`): mirrors Step 5d-pure's `define_registry`). `next/kernel/actor_state.erl`
- On `Create{Person|Service|Group}`: register the actor's profile with state shape `[{ActorId, Profile}, ...]` where `Profile` is a
in `{ActorId => #{type, name, preferredUsername, summary, icon, proplist with `:type / :name / :preferredUsername / :summary /
public_keys, created}}`. :icon / :public_keys / :moved_to / :created`. Maps `#{}` aren't
- On `Update{Person|Service|Group, patch}`: deep-merge the patch. registered in the substrate, so the profile is a property list
- On `Move`: record `:movedTo` pointer. (same shape choice as the kernel's bucket / registry state).
- `next/kernel/actor_state.erl` with `fold_fn/0` plugging into Folding rules:
`projection:start_link/3`. Pure-functional + gen_server-bridged - `Create{Person|Service|Group}` (from a known `:actor`):
tests as a single `actor_state_pure.sh`. 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, - [ ] **2c** — `nx_kernel:bootstrap_actor/4(ActorId, Profile,
KeySpec, State)` — publishes `Create{Person{...}}` as the actor's KeySpec, State)` — publishes `Create{Person{...}}` as the actor's
first activity, exercising the full pipeline. Integration test first activity, exercising the full pipeline. Integration test
@@ -691,6 +700,18 @@ proceed.
Newest first. 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- - **2026-06-06** — Step 2a: genesis Person/Service/Group object-
types. Three new SX files in `next/genesis/object-types/` with types. Three new SX files in `next/genesis/object-types/` with
the same shape as `note.sx` / `sx-artifact.sx` (`:name`, `:doc`, the same shape as `note.sx` / `sx-artifact.sx` (`:name`, `:doc`,