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

@@ -213,18 +213,29 @@ verifying activities published before the rotation.
**Deliverables:**
- Update fold extension: `Update{Person, patch: {add_publicKey: K, supersede: {OldId, NewId}}}`.
- A `key-history` view on actor-state.
- `envelope:verify_signature/2` already does time-aware lookup (M1
§Step 2c); confirm it works against the projection-driven actor-state.
**Tests:**
- Rotation publishes a new key; old key marked superseded.
- Pre-rotation activities verify against the old key.
- Post-rotation activities verify against the new key.
- A rotation activity must itself be signed by an active key with
appropriate purpose (`sign-activity` or `rotate-key`).
- [x] **3** — `actor_state.erl` `fold_update` now routes patches
through `apply_patch/3`, which special-cases two rotation patch
entries:
- `{add_publicKey, KeyProplist}` appends the key to `:public_keys`,
defaulting `:created` to the activity's `:published` if unset.
- `{supersede, OldKeyId}` marks the matching key with
`:superseded_at` = activity's `:published` (idempotent: existing
`:superseded_at` is preserved; unknown ids are no-ops).
Other patch entries fall through to last-write-wins per key
(preserving Step 2b semantics; verified by extra
`actor_state_pure.sh` cases).
New exports `key_history/1` (full list incl. superseded entries),
`active_keys_at/2` (subset active at time T, mirroring envelope's
`is_active_at` semantics — envelope keeps its predicate private,
so a local copy lives here), and `find_key_by_id/2`.
Rotation-purpose schema gating per §9.6 ("rotation activity must
itself be signed by an active key with `rotate-key` purpose") is
deferred to Step 5 (peer-side `stage_signature` will plumb the
purpose check through pipeline). 16 cases in `key_rotation.sh`
cover rotation arithmetic, `key_history` preservation, and live
`envelope:verify_signature/2` round-trips for pre / post / mid
rotation activities — including the negative case (post-rotation
K1-signed activity returns `{error, no_active_key}`).
**Acceptance:** `bash next/tests/key_rotation.sh` passes 12+ cases.
@@ -706,6 +717,26 @@ proceed.
Newest first.
- **2026-06-06** — Step 3 (closes Step 3): key rotation via Update.
`actor_state.erl` `fold_update` routes patches through
`apply_patch/3` which special-cases `{add_publicKey, KeyProplist}`
(append + default `:created` to activity's `:published`) and
`{supersede, OldKeyId}` (mark `:superseded_at`, idempotent).
Other patch entries still last-write-wins per key. New exports
`key_history/1`, `active_keys_at/2`, `find_key_by_id/2` give the
projection-driven view that `envelope:verify_signature/2`
consumes for time-aware lookup. Rotation-purpose schema gating
(`rotate-key` purpose check on the rotation activity itself)
deferred to Step 5 (peer-side stage_signature). `key_rotation.sh`
16/16 covers rotation arithmetic, key_history preservation,
active_keys_at at T=pre, T=rotation, T=post, and live
`envelope:verify_signature/2` round-trips for pre / post / cross
scenarios including the negative-case post-rotation K1 sig.
Conformance 761/761 preserved. 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).
- **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)`