fed-sx-m2: Step 3 — key rotation via Update + actor_state + 16 tests
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).
This commit is contained in:
2026-06-06 13:08:25 +00:00
parent 1fd85e10e6
commit 238a1fbea0
3 changed files with 283 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
-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]).
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
@@ -83,7 +84,8 @@ fold_update(Activity, State) ->
{ok, Profile} ->
case envelope:get_field(patch, Activity) of
{ok, Patch} ->
NewProfile = merge_patch(Profile, Patch),
Published = published_seq(Activity),
NewProfile = apply_patch(Profile, Patch, Published),
set_keyed(ActorId, NewProfile, State);
_ -> State
end;
@@ -127,6 +129,86 @@ 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;