Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
actor_state.erl fold_update routes patches through apply_patch/3
which special-cases two rotation patch entries per design §9.6:
{add_publicKey, KeyProplist}
Append to :public_keys; default :created to activity's
:published if unset.
{supersede, OldKeyId}
Mark the matching key with :superseded_at = activity's
:published. Existing :superseded_at preserved (idempotent);
unknown :id no-op.
Other patch entries still last-write-wins per key (Step 2b semantics
preserved; verified by actor_state_pure 19/19 unchanged).
New exports:
key_history/1 — full :public_keys list (preserves superseded)
active_keys_at/2 — subset active at time T (mirrors envelope's
is_active_at; envelope keeps that predicate
private, so a local copy lives here)
find_key_by_id/2 — lookup by :id in the history
Rotation-purpose schema gating per §9.6 (rotation must be signed
by a key with :rotate-key purpose) is deferred to Step 5 (peer-side
stage_signature will plumb purpose through the pipeline).
16/16 in next/tests/key_rotation.sh covering:
- rotation arithmetic (add_publicKey + supersede combined)
- new key :created = rotation activity's :published
- supersede marks :superseded_at correctly
- key_history preserves all keys (superseded included)
- active_keys_at semantics at T=pre / T=rotation / T=post
- live envelope:verify_signature/2 round-trips:
pre-rotation activity signed with K1 -> ok
post-rotation activity signed with K2 -> ok
post-rotation activity signed with K1 -> {error, no_active_key}
- non-rotation Update patches preserve key history
- add_publicKey alone (no supersede) keeps old key active
- supersede alone empties active set
- supersede with unknown id is a no-op
- second supersede on superseded key is idempotent
Conformance 761/761. 132/132 across 9 Step-3-adjacent suites
(key_rotation, actor_state_pure, actor_lifecycle, envelope_sig,
envelope_shape, envelope_canonical, nx_kernel_multi, bootstrap_start,
smoke_app_pure).
261 lines
9.1 KiB
Erlang
261 lines
9.1 KiB
Erlang
-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,
|
|
key_history/1, active_keys_at/2, find_key_by_id/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} ->
|
|
Published = published_seq(Activity),
|
|
NewProfile = apply_patch(Profile, Patch, Published),
|
|
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.
|
|
|
|
%% apply_patch/3 — same as merge_patch but special-cases two
|
|
%% key-rotation patch entries per design §9.6:
|
|
%% {add_publicKey, KeyProplist} — append a new key to :public_keys,
|
|
%% defaulting :created to Published.
|
|
%% {supersede, OldKeyId} — mark the key with :id =:= OldKeyId
|
|
%% as :superseded_at = Published.
|
|
%% Other patch entries fall through to last-write-wins per key.
|
|
|
|
apply_patch(Profile, [], _Published) -> Profile;
|
|
apply_patch(Profile, [{add_publicKey, NewKey} | Rest], Published) ->
|
|
Augmented = ensure_created(NewKey, Published),
|
|
Current = current_public_keys(Profile),
|
|
NewKeys = Current ++ [Augmented],
|
|
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
|
apply_patch(Profile, [{supersede, OldKeyId} | Rest], Published) ->
|
|
Current = current_public_keys(Profile),
|
|
NewKeys = mark_superseded(OldKeyId, Published, Current),
|
|
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
|
apply_patch(Profile, [{K, V} | Rest], Published) ->
|
|
apply_patch(set_keyed(K, V, Profile), Rest, Published);
|
|
apply_patch(Profile, _, _) -> Profile.
|
|
|
|
current_public_keys(Profile) ->
|
|
case find_keyed(public_keys, Profile) of
|
|
{ok, Keys} -> Keys;
|
|
_ -> []
|
|
end.
|
|
|
|
ensure_created(Key, Published) ->
|
|
case find_keyed(created, Key) of
|
|
{ok, _} -> Key;
|
|
_ -> set_keyed(created, Published, Key)
|
|
end.
|
|
|
|
mark_superseded(_, _, []) -> [];
|
|
mark_superseded(OldId, At, [Key | Rest]) ->
|
|
case find_keyed(id, Key) of
|
|
{ok, OldId} ->
|
|
case find_keyed(superseded_at, Key) of
|
|
{ok, _} -> [Key | mark_superseded(OldId, At, Rest)];
|
|
_ -> [set_keyed(superseded_at, At, Key) | mark_superseded(OldId, At, Rest)]
|
|
end;
|
|
_ -> [Key | mark_superseded(OldId, At, Rest)]
|
|
end.
|
|
|
|
%% Key-history view — full :public_keys list including superseded
|
|
%% entries (per §9.6: history is preserved so historical activities
|
|
%% verify against keys that were active at their :published time).
|
|
|
|
key_history(Profile) ->
|
|
current_public_keys(Profile).
|
|
|
|
%% active_keys_at/2 — the subset of :public_keys active at Now,
|
|
%% mirroring envelope's is_active_at semantics (local copy: envelope
|
|
%% keeps the predicate private).
|
|
|
|
active_keys_at(Profile, Now) ->
|
|
[K || K <- current_public_keys(Profile),
|
|
key_active_at(K, Now)].
|
|
|
|
find_key_by_id(KeyId, Profile) ->
|
|
find_key_by_id_in(KeyId, current_public_keys(Profile)).
|
|
|
|
find_key_by_id_in(_, []) -> not_found;
|
|
find_key_by_id_in(WantId, [K | Rest]) ->
|
|
case find_keyed(id, K) of
|
|
{ok, WantId} -> {ok, K};
|
|
_ -> find_key_by_id_in(WantId, Rest)
|
|
end.
|
|
|
|
key_active_at(Key, Now) ->
|
|
case find_keyed(created, Key) of
|
|
{ok, Created} when Now >= Created ->
|
|
case find_keyed(superseded_at, Key) of
|
|
{ok, SupAt} -> Now < SupAt;
|
|
_ -> true
|
|
end;
|
|
_ -> false
|
|
end.
|
|
|
|
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)].
|