Files
rose-ash/plans/fed-sx-milestone-2.md
giles bf4e034c4e
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
fed-sx-m2: Step 8a — delivery_worker skeleton + 17 tests
next/kernel/delivery_worker.erl is the gen_server-per-peer
delivery queue per design §13.4. Step 8a lands the skeleton:
pure-functional state shape + enqueue / drain / deliver_one
helpers + backoff schedule + gen_server wrapper. No retry
timer wiring yet (Step 8b), no persist projection yet (8c),
no outbox dispatch wiring yet (8d), no httpc BIF yet (8e), no
live HTTP yet (8f).

State shape (pure):
  [{peer, PeerId},
   {pending, [Activity, ...]},          %% FIFO queue
   {attempts, [{Cid, AttemptCount}]},   %% per-cid retry count
   {dead_letter, [Activity, ...]},
   {dispatch_fn, fun/1 | undefined}]

Pure-functional API:
  new/1
  pending/1, peer/1
  enqueue_pure/3       — append to FIFO
  drain_pure/1         — attempt every queued; returns
                         {NewState, DeliveredCids, RetryCids}
  deliver_one_pure/2   — single dispatch via :dispatch_fn

Backoff schedule (§13.4): 30s / 5m / 30m / 6h / 24h then dead_letter
  backoff_for/1   — attempt -> seconds | dead_letter
  schedule_for/1  — attempt -> {retry_in, Sec} | dead_letter

gen_server (registered under peer-id atom):
  start_link/1, start_link/2(PeerId, DispatchFn)
  stop/1
  enqueue/2     — sync call
  flush/1       — drain + reply with {ok, Delivered, Retry}
  pending_srv/1
  set_dispatch_fn/2  — swap dispatch in flight

dispatch_fn is a caller-supplied 1-arity fun so tests can stub the
HTTP POST. Step 8f will plug in a closure over httpc:request/4
without touching the queue logic.

17/17 in next/tests/delivery_worker.sh covering:
  - new/peer/pending base cases
  - enqueue_pure FIFO append
  - drain_pure no-dispatch -> retry, queue intact
  - drain_pure ok dispatch -> queue empties + delivered list
  - drain_pure failing dispatch -> queue intact + retry list
  - deliver_one_pure {ok, Cid} and {error, _, no_dispatch_fn}
  - backoff_for slot values match §13.4
  - backoff_for >=6 returns dead_letter
  - schedule_for wraps the slot or dead_letter
  - gen_server start_link + enqueue + pending_srv
  - gen_server flush with ok dispatch (delivered)
  - gen_server flush with failing dispatch (queue kept)
  - gen_server set_dispatch_fn in-flight swap

Conformance 761/761.
2026-06-07 01:01:17 +00:00

59 KiB

fed-sx Milestone 2 — Multi-actor + Federation

Real federation between two fed-sx instances. Per-actor state, signed inbox delivery, Follow lifecycle, audience-resolving outbound queue, and the rich verbs (Note, Announce, Endorse) needed for federated propagation. Reference: plans/fed-sx-design.md (especially §9 identity, §13 federation, §16 HTTP endpoints). Builds on Milestone 1 (see plans/fed-sx-milestone-1.md).

Goal

Two cooperating fed-sx instances A and B, each hosting one or more actors, can:

  1. Discover each other's actors via webfinger + actor docs.
  2. Follow across instances (FollowAccept → state).
  3. Publish a Note on B and have it land in every follower's actor-state projection on A via signed inbox delivery.
  4. Announce a peer's activity, propagating it to followers of the announcer.
  5. Rotate keys on either side without breaking historical sig verification (per §9.6).

Acceptance: the §11 smoke test (smoke_federate.sh) drives all of the above against two locally-running kernel instances on distinct ports, no human-in-the-loop, and exits 0.

Non-goals (what milestone 2 deliberately does NOT do)

  • Real WAN federation. Both instances run on localhost:PortA and localhost:PortB. Cross-instance HTTP is unencrypted plaintext. TLS, NAT traversal, and signed HTTP-message headers (per RFC 9421) are v3.
  • ActivityPub Mastodon interop. No HTTP-signatures-2018 compat layer, no Linked-Data-Signatures, no JSON-LD canonicalisation. Cross-fed-sx only.
  • IPFS / S3 storage backends. Still local files only.
  • Browser client + operator dashboard. Curl-shaped API only.
  • Capability tokens / delegation. Multi-actor means multi-user, not multi-device for a single actor. Capability tokens (per §9.5) defer.
  • Cross-host conformance. Only OCaml/Erlang-on-SX host runs fed-sx in v2.
  • Performance work. Functional correctness first.
  • Spam/abuse infrastructure. Per §13.6 the layers are designed; v2 implements signature verification + replay defense; reputation, rate-limiting, instance allowlists / blocklists are v3.
  • Operator quarantine UX. Logs only.

Architecture summary

                   Instance A                     Instance B
                   (port 9999)                    (port 9998)

  Outbox     ┌─────────────────┐               ┌─────────────────┐
  ────────▶ │ HTTP server      │               │ HTTP server      │
            │  POST /activity  │               │  POST /activity  │
            │  POST /inbox     │               │  POST /inbox     │
            │  GET  /actors/.. │               │  GET  /actors/.. │
            │  GET  /.well-    │               │  GET  /.well-    │
            │       known/*    │               │       known/*    │
            └────────┬─────────┘               └────────┬─────────┘
                     │                                  │
            ┌────────▼─────────┐               ┌────────▼─────────┐
            │ nx_kernel        │ ◀ HTTPS ▶    │ nx_kernel        │
            │ multi-actor      │   deliveries  │ multi-actor      │
            │  bucket map      │   (signed)    │  bucket map      │
            │   ActorA -> {…}  │               │   ActorB -> {…}  │
            │   ActorC -> {…}  │               │                  │
            └────────┬─────────┘               └────────┬─────────┘
                     │                                  │
            ┌────────▼─────────┐               ┌────────▼─────────┐
            │ Delivery queue   │               │ Delivery queue   │
            │ (one worker per  │               │ (one worker per  │
            │  peer instance)  │               │  peer instance)  │
            └──────────────────┘               └──────────────────┘
                     │
                     │ HTTP POST /inbox to peer
                     ▼
              (peer instance)

The federation transport is plain HTTP POST of canonical-bytes-signed activities to each follower's actor inbox. Delivery is push (§13.1); pull

  • relay deferred to v3.

Build order

Twelve steps in dependency order.

Step Title Depends on
1 Per-actor state buckets in nx_kernel M1 closeout
2 Actor lifecycle activities (Person/Service/Group) Step 1
3 Key rotation via Update + actor-state projection Steps 2, M1 §9.6
4 Multi-actor HTTP routing (per-actor outbox/inbox) Steps 1, M1 8b-start
5 POST /inbox: peer signature verify + ingestion Steps 3, 4
6 Follow lifecycle (Follow / Accept / Reject / Undo) Step 5
7 Audience-resolving delivery set computation Step 6
8 Outbound delivery queue + retry/backoff Step 7
9 Backfill modes on Follow accept Steps 6, 8
10 Discovery: webfinger + actor doc fetch Step 4
11 Rich verbs as runtime artifacts (Note, Announce, Endorse) Step 8
12 Two-instance smoke test (smoke_federate.sh) Steps 1-11

Steps 1-3 are the multi-actor foundation. Steps 4-10 are the federation core. Steps 11-12 close the proof points.


Step 1 — Per-actor state buckets

Today nx_kernel holds one actor's state at the top of its property list. Make it bucketed by ActorId so a single kernel can host any number of actors.

Deliverables:

  • 1a — Pure-functional bucket APIs. State shape becomes [{actors, [{ActorId, ActorBucket}, ...]}, {next_actor_seq, N}] with ActorBucket = [{key_spec, KS}, {actor_state, AS}, {log, L}, {projections, [Name]}, {next_published, N}]. New exports: new/0, add_actor/4, has_actor/2, actors/1, actor_count/1, next_actor_seq/1, actor_bucket/2, publish/3, per-actor accessors (actor_log_state/2, actor_log_tip/2, actor_key_spec/2, actor_state/2, actor_projections/2, actor_next_published/2), with_actor_projections/3. Legacy single-actor accessors (actor_id/1, key_spec/1, actor_state/1, log_state/1, log_tip/1, projections/1, next_published/1, with_projections/2, legacy publish/2) continue to read from the first bucket — every M1 test passes via bootstrap:start/3new/3 → first-bucket lookup. lists:keymember/keyfind not in the substrate; local has_keyed/find_keyed/set_keyed/ set_bucket helpers handle the keyed-list ops. next/tests/nx_kernel_multi.sh 17/17.
  • 1b — Multi-actor gen_server. start_link/3 still seeds bucket 0; new exports add_actor/3, publish_to/2(ActorId, Request), log_tip_for/1, actors/0, state_for/1, bucket_for/1, with_projections_for/2 delegate to the pure- functional bucket APIs via fresh handle_call branches. Existing publish/1/log_tip/0/with_projections/1 route through bucket 0 unchanged. Per-actor mailbox concurrency (one gen_server per bucket so distinct-actor publishes don't serialise) is forward- looking — deferred to Step 4 (multi-actor HTTP routing) where it actually pays off. nx_kernel_multi.sh extended with 9 gen_server cases (26 total), every M1 nx_kernel-adjacent + http suite still green (134 / 134 across 12 suites).

Acceptance: bash next/tests/nx_kernel_multi.sh passes 12+ cases.


Step 2 — Actor lifecycle activities

Per design §9.1, an actor is a Person, Service, or Group object, created by Create{Person{...}}. The kernel needs to fold this into an actor-state projection that downstream code can read for keys, publicKey rotation history, profile fields, follower counts, etc.

Deliverables:

  • 2a — Genesis additions: DefineObject{Person} / DefineObject{Service} / DefineObject{Group} — three new SX files in next/genesis/object-types/ plus manifest entries (now 13 object-types total, 34 total genesis entries). Each defines :name, :doc, :schema (fn (obj) (string? (-> obj :name))). next/tests/genesis_parse.sh extended +7 cases (head form + :name + manifest membership), now 57/57. Bootstrap suite count assertions bumped (bootstrap_read.sh 15/15, 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). 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.
  • 2cnx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State) — adds an actor bucket and publishes Create{Person|Service|Group} as the bucket's first activity in one call. Profile carries :type (defaults to person), :name, :preferredUsername, :summary, :icon, :public_keys; the function builds the Create's :object from the profile and the kernel-side AS from :public_keys. gen_server variant bootstrap_actor/3 for live-kernel use; integration test in actor_lifecycle.sh ties 2a artefacts, 2b projection, and 2c bootstrap together end-to-end (pure + gen_server + projection capture for all three actor types). 15/15.

Acceptance: bash next/tests/actor_lifecycle.sh passes 10+ cases.


Step 3 — Key rotation via Update + actor-state

Per §9.2: rotation is itself an activity. The actor-state projection keeps the full key history (with created / superseded_at) so envelope:verify_signature/2 continues to find historical keys when verifying activities published before the rotation.

Deliverables:

  • 3actor_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.


Step 4 — Multi-actor HTTP routing

Per-actor URLs per design §16.1:

GET  /actors/<id>                  # actor doc
GET  /actors/<id>/outbox           # OrderedCollection
GET  /actors/<id>/outbox?page=N    # page
POST /actors/<id>/inbox            # peer delivery to this actor
GET  /actors/<id>/followers        # follower list
GET  /actors/<id>/following        # following list
POST /activity                     # authenticated publisher API (existing)

POST /activity still picks the publishing actor from the bearer token; the token now maps to an :actor_id rather than a fixed alice.

Deliverables:

  • 4a — Per-actor URL routing. New split_first_slash/1 helper splits the /actors/ suffix into {Id, SubPath}. GET dispatch routes outbox / inbox / followers / following sub-paths to four new content-negotiated response functions (actor_outbox_response_for/2, actor_inbox_get_response_for/2, actor_followers_response_for/2, actor_following_response_for/2) — text / json / activity_json / sx variants per existing format pattern. POST dispatch routes inbox to a 202 Accepted stub (actor_inbox_post_response/0 + accepted_response/1). Unknown sub-paths under /actors/<id>/ return 404. Bare /actors/<id> keeps the M1 actor_doc_response_for arm. 17 cases in http_multi_actor.sh.
  • 4b — Token → ActorId map. New resolve_token/2 reads :tokens from Cfg (proplist [{Token, ActorId}, ...]) and returns {ok, ActorId} on match. Falls back to the M1 :publish_token single-token field on miss (returns {ok, legacy}, route through nx_kernel:publish/1 to bucket 0 unchanged). Cfg with both fields: :tokens wins for matched tokens; :publish_token only consulted on :tokens miss. handle_post_activity now threads the resolved ActorRef to publish_if_kernel/3 which dispatches publish_to/2 for explicit actor ids and publish/1 for the legacy atom. No-kernel auth-only path unchanged. The dead M1 expected_token/1 helper is gone. 8 new cases in http_multi_actor.sh (25/25 total).
  • 4chttp_server:route/3(Req, Cfg, Kernel) is sugar that folds the Kernel reference (typically the registered nx_kernel atom) into Cfg as {kernel, Kernel}. The dispatch chain gained a Cfg arg threaded all the way to per-actor sub-resource handlers (dispatch/3dispatch/4, actor_get/2actor_get/3, actor_subresource_get/3 → /4). The outbox sub-resource handler now reads :kernel and, when the actor exists in the kernel, renders tip: <N> in text / JSON / SX variants — proving the plumbing works end-to-end. Unknown actors or unregistered kernels fall back to the 4a stub. try/of/catch around gen_server:call deadlocks in this port's scheduler (probably the catch-frame mask defers reply delivery); the live handler does a bare nx_kernel:log_tip_for/1
    • integer guard instead. 8 new cases in http_multi_actor.sh (33/33 total).
  • 4d — Per-actor outbox listing reads from the named bucket's log entries via new nx_kernel:log_state_for/1 gen_server export. actor_outbox_full_response_for/5 renders text / JSON / SX bodies with :tip, :page, and the page's :items CID list. Empty pages degrade to the 4c tip-only body to preserve back-compat with epochs 50-57. ?page=N pagination parsed at route/2 time and threaded via Cfg as {request_query, Q}; page_size/0 returns 5 (proof of concept — production picks 20+). 8 new cases in http_multi_actor.sh (41/41 total). Substrate gotcha: named recursive funs fun F(...) -> ... F(...) end not supported; binary:matches/2 and lists:foreach/2 not registered — tests prove behaviour via match_prefix substring checks rather than counting.
  • 4e — POST /actors//inbox stays the 4a 202 stub through 4a-4d; the real ingestion pipeline (sig verify + inbox- bucket append + projection broadcast) is Step 5's whole topic. No code change for this checkbox — it's a deliberate scope boundary so 4d's listing semantics land cleanly before inbound traffic shapes the same per-actor URLs.

Acceptance: bash next/tests/http_multi_actor.sh passes 14+ cases.


Step 5 — POST /inbox: signature verify + ingestion

The receiving side of federation. A peer instance POSTs a signed activity to /actors/<id>/inbox; the kernel verifies the signature, runs the inbound validation pipeline, appends to the receiving actor's log (separate from outbox — the inbox is its own log for activities the actor received), and broadcasts to projections.

Deliverables:

  • 5a — Per-actor :actor_inbox log bucket in nx_kernel. add_actor/4 now opens a fresh inbox log (distinct base stub) for each new actor; the bucket carries [..., {actor_inbox, LogState}, ...] alongside the existing :log outbox field. Pure-functional exports: actor_inbox_state/2, actor_inbox_tip/2, append_to_actor_inbox/3. gen_server exports: inbox_tip_for/1, inbox_state_for/1, append_inbox/2. Inbox and outbox tips are fully independent (appending to one doesn't touch the other). next/tests/inbox_bucket.sh 14/14. Signature verification + pipeline gating live in 5b.
  • 5b — Inbound validation pipeline. New pipeline:validate_inbound/3(Activity, PeerActorState, InboxLog) runs the federation inbound stage list — stage_envelopestage_signature(PeerAS)stage_replay(InboxLog) — halting on the first failure. New helper inbound_stages/2(PeerAS, InboxLog) exposes the stage list for callers that want to splice extra stages. Existing validate_inbound/1 and the static inbound_stages/0 (envelope-only) stay untouched so outbox-side callers don't have to re-key on a peer-AS they don't have. Sig verification uses the peer's actor-state :public_keys, NOT the local kernel's; peer-AS resolution is the caller's responsibility (Step 5c wires the cache lookup). 14 cases in inbox_pipeline.sh: happy path, bad shape, missing :signature (rejected by stage_envelope before sig runs), wrong peer AS (bad_signature), replay against inbox, distinct activities both verify, stage short-circuit ordering verified.
  • 5c — Peer-actors cache (peer_actors.erl). State shape [{PeerActorId, PeerActorState}, ...] keyed by atom; PeerAS is exactly the shape envelope:verify_signature/2 reads (proplist with :public_keys). Pure exports: new/0, lookup/2, store/3, evict/2, peers/1, and the load-bearing lookup_or_fetch/3(PeerId, FetchFn, State) that calls the caller-supplied FetchFn :: (PeerId) -> {ok, PeerAS} | {error, _} on miss and stores the successful result. Failed fetches do NOT poison the cache so callers can retry on transient errors. gen_server wrapper: start_link/0,1, lookup_srv/1, store_srv/2, lookup_or_fetch_srv/2, peers_srv/0, evict_srv/1. start_link/1 accepts an initial state proplist for tests / fixtures. 19/19 in peer_actors.sh. The actual fetch implementation (HTTP GET of the peer's actor doc) is Step 5d's responsibility — for 5c, FetchFn is just a contract.
  • 5d — http_server inbox handler wires the chain. POST /actors//inbox is now special-cased in route/2 (next to POST /activity) so the body + full Cfg reach the handler. New handle_inbox_post/3 orchestrates: kernel_has_actordecode_activity (term_codec wire format) → resolve_peer_as (Cfg :peer_as map > :peer_actors srv > :peer_fetch_fn fallback) → pipeline:validate_inbound/3nx_kernel:append_inbox. Status codes:
    • 202 Accepted on pipeline ok + inbox append
    • 401 Unauthorized on bad_signature / no_signature / unknown peer / fetch error
    • 404 Not Found on unknown target actor
    • 422 Unprocessable on shape / decode / replay failure v1 stub actor_post/1 removed; the route/2 special case supersedes it. M1 actor_inbox_post_response/0 kept for callers that need to compose the response shape. Projection broadcast on success is intentionally deferred — the same TODO covers outbox broadcast invariance and lands in a follow-up sub-deliverable. inbox.sh 11/11 covers happy path / shape / sig / replay / unknown-target / multi-message; inbox_peer_resolution.sh 6/6 covers the four peer-AS resolution paths. Tests split into two files because the cumulative cost of one kernel start_link per epoch pushed a single suite past the wall-clock budget.

Acceptance: bash next/tests/inbox.sh passes 16+ cases.


Step 6 — Follow lifecycle

Per §13.2:

(activity 'Follow                            ;; from A → B
  :object actor-id-B
  :to (list actor-id-B))

B responds with Accept (or Reject); A's follower-graph projection tracks the state. Undo{Follow} reverses it.

Deliverables:

  • 6afollower_graph.erl Erlang-fun stand-in for the genesis follower-graph.sx projection body. State shape is a property-list keyed by ActorId (maps #{} not in substrate), each entry carries {following, followers, pending_outbound, pending_inbound} lists. Fold rules:
    • Follow{actor: A, object: B} — A → pending_outbound(B); B → pending_inbound(A).
    • Accept{actor: B, object: F=Follow{A→B}} — A → following(B) on A's bucket; B → followers(A) on B's bucket; pendings cleared.
    • Reject{actor: B, object: F} — pendings cleared, no promote.
    • Undo{actor: A, object: F} — drops A↔B from every list; only F's original actor can Undo (carol can't Undo F{A→B}). Self-follows are no-ops; duplicate Follows are idempotent; Accept/Reject/Undo of non-Follow :objects pass through. 18 cases in follower_graph.sh. The fold_fn/0 2-arity fun plugs into projection:start_link/3 exactly like define_registry:fold_fn/0 and actor_state:fold_fn/0.
  • 6b — Wire follower-graph fold to the inbox handler. http_server.erl run_inbox_pipeline now calls broadcast_to_inbox_projections/2 after a successful nx_kernel:append_inbox. Cfg may carry {inbox_projections, [Name, ...]} listing projection gen_servers; each gets the activity via projection:async_fold/2 (fire-and-forget so the handler doesn't block on fold processing). Field absent = no-op. v2 leaves the routing field global; per-actor projection wiring is a forward-looking follow-up. 9/9 in follow_lifecycle.sh covering 202 ingestion, follower_graph pending-state mutation on both sides, no-inbox_projections no-op path, bad-sig short-circuit (projection stays clean), multi-peer accumulation, end-to-end Follow+Accept projection convergence (Accept fed in via projection:async_fold for v2).
  • 6c — Auto-Accept publish. New maybe_auto_accept/3 in http_server.erl fires after a successful inbox ingestion if Cfg carries {auto_accept_follows, true} AND the activity's :type is follow. The handler constructs an Accept{actor: target, object: OriginalFollow} request and routes it through nx_kernel:publish_to/2, which goes through the full outbox pipeline (envelope construct + HMAC sign + log append + outbox projection broadcast). When the target's outbox :projections list includes the same follower_graph projection the inbox uses, the Accept fold-converges the bilateral relationship — alice.followers = [bob] and bob.following = [alice] — without any test scaffolding. Default is off; manual-moderation deployments leave the flag unset. Bad-sig / non-Follow ingestion short-circuits before the Accept attempt. 9/9 in auto_accept.sh.

Acceptance: bash next/tests/follow_lifecycle.sh passes 14+ cases.


Step 7 — Audience-resolving delivery set

For each outbound activity, compute the set of inbox URLs to POST to. Sources: explicit :to + :cc recipients, plus Public / Followers expansion via the audience predicates from M1's genesis bundle.

Deliverables:

  • 7adelivery:delivery_set/2,3 returns the audience-resolved deduplicated list of ActorId atoms for an outbound activity. Sources: explicit :to and :cc fields (atom or list of atoms / audience symbols), plus expansion of followers (via follower_graph) and public (v2 placeholder — Step 7c). Self-delivery is suppressed every time the sender's ActorId appears in the set. Returns are ActorId atoms for now; Step 8 will resolve each entry to {PeerInstanceUrl, ActorId} via the peer-actors cache. 17 cases in delivery_set.sh covering empty / single / list / cc-union / self-suppress / dedup / followers-expand / public-empty / mixed audience / collect_recipients + suppress_self + dedup helpers + expand_audience pass-through. Module lives in next/kernel/delivery.erl (separate from outbox so Step 8's delivery-queue gen_server has a clean home).
  • 7b — Public audience expansion. v2 default: public expands to the sender's followers (same as followers) per design §13.4 — the practical fan-out for an open social network is "every follower of the publishing actor". The explicit shared-inbox peer-instance model (Mastodon-style per-instance broadcast) defers to v3 when there's a real known-peer-instance registry to drive it. public + followers in the same audience deduplicates because both symbols expand identically. 19/19 in delivery_set.sh (2 new cases
    • 1 case updated from the v2 placeholder behavior).
  • 7c — Outbox-side integration. outbox:publish/2 now computes the delivery set after sign + log and stashes it in the Result proplist as {delivery_set, [ActorId, ...]}. Context's optional :follower_graph field carries a follower_graph state for public / followers audience expansion; absent -> empty graph (explicit :to/:cc recipients still resolve). New helper compute_delivery_set/3(Request, Signed, Context) and recipients_envelope/2 synthesise a minimal recipient envelope from Request's :to/:cc + Signed's :actor so delivery:delivery_set/3 can process it unchanged (outbox:construct/4 doesn't carry :to/:cc through the envelope shape, and changing that surface would ripple to every existing envelope test). Step 8's delivery-queue worker will read {delivery_set, [ActorId, ...]} off the publish result. 17/17 in outbox_publish.sh (+4 new cases: empty-default, explicit-:to, followers-symbol-via-graph, self-suppression). Module load chain rebumped from epoch 5 to epoch 7 (adds follower_graph + delivery as dependencies) and the test's internal sx_server timeout bumped 240s → 480s to fit the larger module set.

Step 8 — Outbound delivery queue

Per §13.4: every queued delivery has retry semantics. v2 uses one gen_server-per-peer-instance worker holding a small queue. Failures back off exponentially; permanent failures (HTTP 410, bad TLS) move to a dead-letter list visible via /admin/dead-letter.

Deliverables:

  • 8adelivery_worker.erl skeleton: pure-functional state shape [{peer, _}, {pending, [_]}, {attempts, [{Cid, N}]}, {dead_letter, [_]}, {dispatch_fn, _}] plus enqueue_pure/3, drain_pure/1, deliver_one_pure/2 and the backoff schedule (backoff_for/1, schedule_for/1) matching §13.4 (30s / 5m / 30m / 6h / 24h then dead-letter). gen_server wrapper with start_link/1,2, enqueue/2, flush/1, pending_srv/1, set_dispatch_fn/2. dispatch_fn is a caller-supplied 1-arity fun so tests can stub the HTTP POST; Step 8f plugs in the live httpc call without touching the queue logic. No actual HTTP yet; no retry timer wiring yet. 17/17 in delivery_worker.sh.
  • 8b — Retry / backoff scheduler. Wire schedule_for/1 into a private retry loop: flush/1 returns deliveries that failed; the worker schedules a self-cast via Erlang after timer for the next retry slot. Tests fake-time via a Cfg :now_fn.
  • 8c — Delivery-state projection so the queue survives kernel restart. New next/kernel/delivery_state.erl fold maps enqueue / delivered / failed events to the worker's persistent shape.
  • 8doutbox:publish/2 dispatches each delivery-set entry to the matching worker. The worker is created lazily on first delivery to a peer.
  • 8ehttpc:request/4 BIF wrapper in lib/erlang/runtime.sx (the briefing's allowed scope exception for Step 8). Marshalling: SX dict ↔ Erlang proplist shape with {ok, Status, Headers, Body} / {error, Reason}.
  • 8f — Real HTTP dispatch through the BIF + content-type wiring. dispatch_fn for live use becomes a closure over the peer URL that calls httpc:request/4 with the signed envelope bytes as the body.

Tests:

  • Successful delivery → worker queue empties.
  • Failed delivery → backoff schedule respected.
  • Dead-letter after max attempts.
  • Cross-restart: queue restored from delivery-state projection.
  • Concurrent deliveries to multiple peers don't serialise.

Acceptance: bash next/tests/delivery_queue.sh passes 16+ cases.


Step 9 — Backfill on Follow accept

Per §13.3: A wants B's history when A first follows B. Four modes:

Mode Behavior
none New follower sees only forward-going content
last-N Backfill last N activities
last-T Backfill last T duration of activities
full Backfill entire outbox

Deliverables:

  • Follow activity may carry :backfill {:mode :last-N :limit 100}.
  • On Accept, B's outbox is GET-paged with appropriate filters.
  • GET /actors/<id>/outbox?since=Cid&limit=N returns a paged response.
  • Backfill bodies wrap the original activities in :backfilled true so projections can decide whether to re-fold or skip.

Tests:

  • last-N mode delivers exactly N most-recent activities.
  • last-T mode delivers everything published since now - T.
  • full mode delivers everything, page by page.
  • none mode delivers nothing.
  • Backfilled activities preserve original :id (CID).

Acceptance: bash next/tests/backfill.sh passes 12+ cases.


Step 10 — Discovery

Per §13.7: webfinger plus actor doc fetch.

Deliverables:

  • GET /.well-known/webfinger?resource=acct:alice@<host> returns the actor URL.
  • GET /actors/<id> returns the actor doc (already exists from M1 Step 8c-actors).
  • Peer-actor cache: when verifying a peer's signature for the first time, fetch their actor doc, store in peer-actors projection.
  • discovery:resolve/1("acct:alice@host:port") returns the actor URL.

Tests:

  • Webfinger for known actor → 200 with links[].href.
  • Webfinger for unknown → 404.
  • Cross-instance: A resolves an acct on B → fetch succeeds.
  • Actor-doc fetch caches the result.
  • Cache invalidation on key rotation (v3 — for now, no TTL).

Acceptance: bash next/tests/discovery.sh passes 12+ cases.


Step 11 — Rich verbs as runtime artifacts

Per the verb-extensibility proof point (M1 §9a), new verbs land as DefineActivity artifacts published into the genesis-equivalent boot log, not as kernel code changes. v2 adds:

Verb Object shape Use case
Note {content, tags?} Short authored message
Announce {object: <ActivityCid>} Propagate a peer's activity to followers
Endorse `{object: , kind: like share}`

Announce is the critical one for federation — it lets one actor re-broadcast another actor's content to their own followers.

Deliverables:

  • Three new SX files in a next/genesis/runtime-verbs/ directory.
  • Each is shipped to a fresh instance via a bootstrap manifest entry or published as the first activity on the actor's outbox; either works because of the verb-extensibility mechanism.
  • Announce-specific delivery: the announced activity's CID is included in the Announce; followers can re-fetch the referenced activity from the original instance if their projection wants to fold the body.

Tests:

  • Define + publish Note works end-to-end.
  • Define + publish Announce wraps another activity by CID.
  • Announce delivery: A announces B's Note; A's followers see the Announce; their feed projection optionally fetches the wrapped Note.
  • Endorse increments an endorsement counter on the target Activity.
  • Verb registration is observable in the define-registry projection.

Acceptance: bash next/tests/rich_verbs.sh passes 14+ cases.


Step 12 — Two-instance smoke test

The proof point. next/tests/smoke_federate.sh spins up two kernel instances on distinct ports, walks them through the full federation flow, and exits 0.

Test outline:

# 0. Start two instances: A on 9999, B on 9998
./next/scripts/start_pair.sh

# 1. Bootstrap two actors: alice@A, bob@B
curl -X POST :9999/activity \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"type":"Create","object":{"type":"Person","name":"alice"}}'

curl -X POST :9998/activity \
  -H "Authorization: Bearer $TOKEN_B" \
  -d '{"type":"Create","object":{"type":"Person","name":"bob"}}'

# 2. alice@A discovers bob@B via webfinger
curl :9999/.well-known/webfinger?resource=acct:bob@localhost:9998

# 3. alice follows bob
curl -X POST :9999/activity \
  -d '{"type":"Follow","object":"http://localhost:9998/actors/bob"}'

# 4. Expect alice's follower-graph: pending_outbound includes bob
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'

# 5. Expect bob auto-accepts; alice's pending_outbound clears
sleep 1
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'

# 6. bob publishes a Note
curl -X POST :9998/activity -d '{"type":"Create","object":{"type":"Note","content":"hi"}}'

# 7. alice's inbox receives the Note
sleep 1
curl :9999/actors/alice/inbox?page=true | jq -e '.orderedItems[] | .type == "Create" and .object.type == "Note"'

# 8. alice's actor-state projection has the new Note
curl :9999/projections/feed | jq -e ". | length > 0"

# 9. Key rotation: bob rotates keys
curl -X POST :9998/activity -d '{"type":"Update","object":"bob","patch":{...}}'

# 10. alice still verifies older Notes against the old key
#     (via actor-state's key history)

# 11. Announce: alice announces bob's Note
curl -X POST :9999/activity -d '{"type":"Announce","object":"<bob-note-cid>"}'

# 12. Verify Announce delivers to alice's followers (zero in v1 but
#     the activity should be in alice's outbox)

# 13. Shutdown both instances; restart; verify state survives
./next/scripts/stop_pair.sh
./next/scripts/start_pair.sh
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'

Acceptance for Step 12: smoke_federate.sh exits 0. The full flow runs without any human-in-the-loop coordination, both instances' projections converge, and a restart preserves all federation state.


Acceptance criteria for milestone 2

All of:

  1. Each step's test suite passes (bash next/tests/<step>.sh).
  2. The federation smoke test passes (bash next/tests/smoke_federate.sh).
  3. Milestone 1 baseline preserved — the entire M1 test suite still passes (~560 assertions across 50 suites).
  4. Erlang-on-SX conformance — adding multi-actor + federation kernel code in next/kernel/*.erl doesn't break Phase 1-8 conformance (currently 761/761).
  5. Restart durability — kill both instances mid-delivery, restart, queues resume, projections converge, no log corruption.
  6. Manual real Mastodon poke — point a Mastodon account at https://next-A.rose-ash.com/actors/alice and verify the actor doc fetches. (Read-only AP interop only — Mastodon Follow is v3 gating on HTTP-Signatures-2018 compat.)

What lands when

Steps 1-3 are sequential (multi-actor foundation). Steps 4-10 are mostly sequential within the federation core but some can parallelise: 4-6 are sequential; 7-9 can interleave after 6 lands.

M1 closeout (HEAD) ──┐
                     │
                     ▼
              ┌─── Step 1 ──┬─── Step 2 ──┬─── Step 3
              │             │             │
              └─────────────┼─── Step 4 ──┘
                            │
                            └─── Step 5 ────┐
                                            │
                                  Step 6 ───┤
                                            │
                                  Step 7 ───┤
                                  Step 8 ───┤
                                  Step 9 ───┤
                                            │
                                  Step 10 ──┤
                                            │
                                  Step 11 ──┤
                                            │
                                  Step 12 ──┘

Estimated effort: ~40-60 commits across all 12 steps. A focused agent loop (loops/fed-sx-m2) should be able to land this with the same discipline as M1.

What's deferred to milestone 3

  • rose-ash port (the headline of M3). Blog, market, events, federation hub, account, orders — all delivered as fed-sx applications. Each existing rose-ash domain becomes DefineApplication{...} artifacts.
  • TLS / HTTP-Signatures-2018 / RFC 9421. Real Mastodon interop.
  • Multi-instance over real WAN. Cross-instance over TLS, NAT traversal, peer instance allowlists.
  • IPFS / S3 storage backends as DefineStorage entries.
  • Browser client + operator dashboard. Probably Elm-on-SX.
  • Cross-host conformance — Python / JS / Haskell hosts running fed-sx with the same conformance corpus.
  • OpenTimestamps proofs as DefineProof entries.
  • Reputation, allowlists, rate-limiting — full §13.6 abuse posture.
  • Performance work — JIT-compiled folds, snapshot acceleration, federation batching, mailbox prioritisation.
  • Capability tokens / delegation — multi-device for a single actor.

Appendix A: open questions for milestone 2

Things still under-specified; resolve as work begins.

  1. Inbox-side stage_signature key fetching. When A receives a POST /inbox from peer instance B for the first time, A needs B's actor doc to verify the signature. Synchronous fetch vs. queue- and-retry? Synchronous is simpler but blocks the inbox handler; queue-and-retry needs deferred validation state. Probably synchronous with a 5s timeout for v2.

  2. Backfill granularity for last-N. N counts forward (oldest first) or backward (newest first)? Forward matches projection-fold semantics; backward matches user expectation. Probably forward for v2, document the choice.

  3. Auto-Accept policy on Follow. v2 ships open-world: every Follow is auto-accepted. Manual moderation (held in a pending list, accepted via /admin/) is v3 with the operator dashboard.

  4. Delivery worker per peer instance vs. per peer actor. Per instance is simpler (one HTTPS connection pool) but throttles inter-actor bandwidth on busy peers. v2 starts with per-instance; per-actor sharding is a perf tweak in §15.

  5. Two-instance test harness. How do we start a pair of kernels in one bash test? Probably bootstrap:start/3 twice with different ActorIds + ports + base paths. Need to confirm nx_kernel can be started under different registered atoms (nx_kernel_a, nx_kernel_b) for the test. Process registration in this port supports arbitrary atom names (verified in M1).

  6. Multi-host conformance. Adding cross-host tests for federation requires Python/JS hosts to implement the v2 spec corpus too. Deferred to v3; v2 conformance is one-host only.

  7. Storage of received activities. When A receives a Note from B via /inbox, does A keep B's signed envelope verbatim (for re-broadcast on Announce), or does A re-construct + re-sign with A's own key? AP-canon: keep verbatim. Confirm at Step 5.


Blockers

Pre-existing regressions inherited from the M1 closeout. Out of m2 scope (substrate, not next/**), tracked here so iteration can proceed.

  1. next/tests/http_server_tcp.sh 0/5 — pre-existing regression introduced by 78eae9ef (fed-sx-m1: 8b-bridge cleanup). lib/erlang/runtime.sx:1593 still references er-http-resp-to-sx and er-http-req-of-sx in er-bif-http-listen's sx-handler body, but the cleanup commit removed both helpers without rewriting the BIF. Listener binds (TCP socket accepts), but every request handler crashes on first call to the undefined helpers — curl gets 000 / empty body. Fix needs to rewrite the sx-handler body around the live er-request-dict-to-proplist / er-proplist-to-dict helpers (which the cleanup commit's message claimed are already in use, but which the BIF body never picked up). Substrate work, belongs on loops/erlang. m2 work continues against the in-process HTTP layer (http_marshal.sh 10/10, http_publish_fold.sh 10/10) until resolved. Confirmed pre-existing by stashing 1a's changes and re-running on the unmodified m1 closeout HEAD.

Progress log

Newest first.

  • 2026-06-07 — Step 8a: delivery_worker skeleton. next/kernel/delivery_worker.erl with pure-functional state + enqueue / drain / deliver_one + backoff schedule (30s / 5m / 30m / 6h / 24h then dead-letter, per design §13.4). gen_server wrapper exposes the same APIs under the peer-id atom. dispatch is a caller-supplied :dispatch_fn fun — Step 8b layers the retry timer, Step 8c persists the queue, Step 8d wires outbox:publish/2 to dispatch, Step 8e brings the httpc:request/4 BIF (substrate exception per briefing), Step 8f closes with live HTTP. 17/17 in delivery_worker.sh. Conformance 761/761.

  • 2026-06-07 — Step 7c (closes Step 7): outbox-side delivery_set integration. outbox:publish/2 computes the audience-resolved delivery set after sign + log and stashes it in the Result proplist as {delivery_set, [ActorId, ...]}. New compute_delivery_set/3(Request, Signed, Context) threads :follower_graph from Context through to delivery:delivery_set/3. recipients_envelope/2 synthesises a minimal envelope from the Request's :to/:cc + Signed's :actor so the existing delivery API works unchanged (envelope construct/4 doesn't carry the audience fields through). 17/17 in outbox_publish.sh (+4 new: empty-default, explicit-:to, followers-symbol-via-graph, self-suppression). Module load order shifted from epoch 5 to epoch 7 to make room for follower_graph + delivery; internal sx_server timeout bumped 240s → 480s. Step 7 fully closed (7a delivery module + 7b public expansion + 7c outbox integration).

  • 2026-06-06 — Step 7b: public audience expansion. delivery:expand_audience(public, Sender, Graph) now returns the sender's followers (same as followers) — per design §13.4 that's the practical fan-out semantics for an open social network. The explicit shared-inbox peer-instance model defers to v3. 19/19 in delivery_set.sh (+2 new cases: public-with-empty-graph, public+followers-dedupe; +1 case updated from the v2 placeholder). Conformance 761/761 preserved.

  • 2026-06-06 — Step 7a: audience-resolving delivery set. New next/kernel/delivery.erl: delivery_set/2,3(Activity, KernelState[, FollowerGraph]) returns a deduplicated list of ActorId atoms — the targets an outbound activity needs to be POSTed to. Sources: :to and :cc fields (single atom or list, atoms or audience symbols), plus expansion of followers via the supplied follower_graph state. public placeholder returns [] for v2; Step 7b will populate via a known- peer-instance set. Self-delivery suppressed. ActorIds for now — Step 8 resolves each entry to {PeerInstanceUrl, ActorId} via peer-actors cache. 17/17 in delivery_set.sh. Conformance 761/761. Lives in its own module (not inside outbox) so the Step 8 delivery-queue gen_server has a clean home.

  • 2026-06-06 — Step 6c (closes Step 6): auto-Accept publish on Follow ingestion. New maybe_auto_accept/3 in http_server.erl fires after successful inbox append + projection broadcast: if Cfg has {auto_accept_follows, true} and the activity is a Follow, construct [{type, accept}, {object, OriginalFollow}] and route through nx_kernel:publish_to/2. The publish goes through the full outbox pipeline (construct + sign + log + projection broadcast), so when the target's outbox :projections share the same follower_graph projection that inbox broadcasts into, the bilateral relationship fold-converges automatically (alice.followers = [bob], bob.following = [alice], both pending lists clear). Default off; bad-sig / non-Follow ingestion short-circuits before the Accept attempt. 9/9 in auto_accept.sh. Conformance 761/761. Step 6 fully closed (6a + 6b + 6c).

  • 2026-06-06 — Step 6b: wire follower_graph fold to the inbox handler. New broadcast_to_inbox_projections/2 in http_server.erl casts every successfully-ingested activity into each :inbox_projections Cfg entry via projection:async_fold/2. Fire-and-forget so the inbox handler doesn't block on fold processing. Empty / absent :inbox_projections is a no-op (back-compat with Steps 5d callers). 9/9 in follow_lifecycle.sh covering 202 + bilateral pending-state mutation + bad-sig short-circuit + multi-peer

    • end-to-end projection convergence on Follow+Accept. Conformance 761/761. Auto-Accept publish (the receiving kernel responds with a signed Accept) is Step 6c.
  • 2026-06-06 — Step 6a: follower-graph projection (follower_graph.erl). Pure-functional fold over Follow / Accept / Reject / Undo activities per design §13.2. State is a proplist keyed by ActorId carrying {following, followers, pending_outbound, pending_inbound} lists. Follow pushes onto pendings; Accept moves both sides from pendings into the permanent lists; Reject just clears pendings; Undo drops the pair everywhere (and only the Follow's original actor can Undo). Self-follow is a no-op; duplicate Follow is idempotent; Accept/Reject/Undo of a non-Follow :object passes through. fold_fn/0 is the standard 2-arity fun for projection:start_link/3 (same shape as actor_state and define_registry). 18/18 in follower_graph.sh. Conformance 761/761.

  • 2026-06-06 — Step 5d: POST /actors//inbox real ingestion. route/2 now special-cases POST /actors/<id>/inbox next to POST /activity so the body + full Cfg reach the new handle_inbox_post/3 handler. Flow: kernel_has_actor -> decode_activity (term_codec wire format) -> resolve_peer_as (Cfg :peer_as map > :peer_actors srv > :peer_fetch_fn fallback) -> pipeline:validate_inbound/3 -> nx_kernel:append_inbox. Status codes 202 / 401 / 404 / 422 per design §16.1. v1 stub actor_post/1 removed; M1 actor_inbox_post_response/0 kept for response shape composition. Projection broadcast on inbox success intentionally deferred to a follow-up. inbox.sh 11/11 (basic ingestion: happy path / shape / sig / replay / unknown-target / multi-message); inbox_peer_resolution.sh 6/6 (peer-AS resolution variants). Split into two files because cumulative per-epoch kernel start_link + outbox construct + term_codec encode pushed a single suite past the wall-clock budget. http_server.erl now 1181 lines — load time on this Erlang port scales superlinearly with function count, so eight http_*.sh tests' internal sx_server timeout bumped 60s → 360s. Conformance 761/761.

  • 2026-06-06 — Step 5c: peer-actors cache (peer_actors.erl). Pure-functional cache of {PeerActorId, PeerAS} entries with the load-bearing lookup_or_fetch/3(PeerId, FetchFn, State) entry: cache hit returns stored PeerAS unchanged; miss calls FetchFn(PeerId), stores success, returns {ok, PeerAS, NewState}. Fetch errors don't poison the cache so callers can retry on transient HTTP failures. gen_server wrapper exposes the same shape under registered name peer_actors; start_link/1 accepts an initial proplist for tests. Per-design v2 fetches are synchronous over plaintext HTTP; the actual http-client call lands in Step 5d. 19/19 in peer_actors.sh. Conformance 761/761. 139/139 across 9 Step-5-adjacent suites.

  • 2026-06-06 — Step 5b: federation inbound pipeline. pipeline:validate_inbound/3(Activity, PeerAS, InboxLog) runs stage_envelopestage_signature(PeerAS)stage_replay(InboxLog) in order, halting on first failure. New inbound_stages/2 helper returns the 3-stage list. M1's validate_inbound/1 + static inbound_stages/0 (envelope-only) preserved for outbox- side callers. 14/14 in inbox_pipeline.sh covering happy path, bad shape, missing :signature, wrong peer AS, replay against inbox, distinct activities both verify, stage short-circuit ordering. Sig verification routes through the peer's AS (not the local kernel's) — Step 5c will wire the cache lookup. Conformance 761/761. 130/130 across 10 Step-5-adjacent suites (pipeline_envelope, pipeline_signature, pipeline_replay, pipeline_driver, inbox_pipeline, inbox_bucket, nx_kernel_multi, bootstrap_start, http_publish, outbox_publish, smoke_app_pure).

  • 2026-06-06 — Step 5a: per-actor :actor_inbox log bucket. nx_kernel.erl add_actor/4 now opens a fresh log via log:open/2 with a distinct inbox_base_stub() for each new bucket and stores it as {actor_inbox, LogState} alongside the existing outbox :log. Pure exports actor_inbox_state/2, actor_inbox_tip/2, append_to_actor_inbox/3 + gen_server exports inbox_tip_for/1, inbox_state_for/1, append_inbox/2. log:append/2 is (LogState, Activity) -> {ok, NewState, Seq} — noted for future iterations. Inbox / outbox tips are fully independent. next/tests/inbox_bucket.sh 14/14. Conformance 761/761. 125/125 across 7 Step-5-adjacent suites (inbox_bucket, nx_kernel_multi, nx_kernel_server, bootstrap_start, http_publish, http_multi_actor, actor_lifecycle, smoke_app_pure).

  • 2026-06-06 — Step 4d: per-actor outbox listing + pagination. New nx_kernel:log_state_for/1 gen_server export returns {ok, LogState} for an actor. actor_outbox_response_for/3 now extracts {Tip, Entries} via kernel_actor_log_data/2, parses ?page=N from the Req's :query field (threaded through Cfg as {request_query, Q}), and renders a paged body. Text body adds page: N\nitem: <cid>\n...; JSON adds "page":N,"items":[...]; SX adds :page N :items (...). Empty pages (out-of-range or actor-with-no-publishes) degrade back to the 4c tip-only shape, preserving epochs 50-57. page_size/0 is 5 for tests (production picks 20+). 8 new cases in http_multi_actor.sh (41/41 total). Conformance 761/761. 117/117 across 11 Step-4-adjacent suites. Gotcha noted: named recursive funs fun F(...) -> ... F(...) end fail with "fun-ref syntax not yet supported"; binary:matches/2 and lists:foreach/2 aren't registered in this substrate.

  • 2026-06-06 — Step 4c: route/3 with kernel access. http_server:route/3(Req, Cfg, Kernel) folds the kernel reference into Cfg as {kernel, _}. Dispatch chain refactored to thread Cfg through to per-actor sub-resource handlers. Outbox handler reads :kernel and renders tip: <N> (in text / JSON / SX content-negotiated variants) when the actor exists; falls back to the 4a stub otherwise. Substrate quirk found: try/of/catch around gen_server:call deadlocks in this port's scheduler — bare call + integer guard works. Inbox / followers / following handlers accept Cfg but ignore it; real state lookup lands in 4d/4e/Step 5+. 8 new cases in http_multi_actor.sh (33/33 total). Conformance 761/761. 121/121 across 10 Step-4-adjacent suites. Gotcha noted for future iterations: avoid try/catch wrapping gen_server calls in this substrate.

  • 2026-06-06 — Step 4b: token -> ActorId map. Cfg's :tokens proplist ([{Token, ActorId}, ...]) maps bearer tokens to per-actor publishers. handle_post_activity threads the resolved ActorRef to publish_if_kernel/3 which calls nx_kernel:publish_to/2 for explicit actor ids and publish/1 for the back-compat legacy atom (M1's :publish_token single-token field still works as-is). When both fields are present, :tokens takes precedence; :publish_token is the fallback on miss. Dead expected_token/1 helper removed. 8 new cases in http_multi_actor.sh (25/25 total) covering two-actor token routing, log-tip isolation, interleaved publishes, bad-token 401, back-compat coexistence, no-kernel stub path. Conformance 761/761 preserved. 116/116 across 10 Step-4-adjacent suites.

  • 2026-06-06 — Step 4a: per-actor HTTP sub-paths. New split_first_slash/1 helper lets GET / POST /actors/<id>/... paths route on the sub-segment (outbox, inbox, followers, following). Four new content-negotiated response stubs (actor_outbox_response_for/2, actor_inbox_get_response_for/2, actor_followers_response_for/2, actor_following_response_for/2) with text / json / activity_json / sx variants, mirroring the existing actor_doc_response_for/2 shape. POST /actors/<id>/inbox returns a 202 Accepted stub (actor_inbox_post_response/0 + accepted_response/1); real ingestion pipeline lands in Step 5. Unknown sub-paths return 404. Bare /actors/<id> keeps the M1 actor-doc arm intact — http_route and http_post_format regression suites unchanged (10/10 each). 17/17 in http_multi_actor.sh. Conformance 761/761 preserved. 120/120 across 10 Step-4-adjacent suites.

  • 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) adds an actor bucket via add_actor/4, derives the kernel AS proplist from Profile's :public_keys, builds a Create envelope wrapping the profile's :type (defaults person) + field set, and calls publish/3. gen_server variant bootstrap_actor/3 for live-kernel use plus a corresponding handle_call branch. actor_lifecycle.sh 15/15 covers pure bootstrap (log_tip advances, Create-shape, dup detection), two-actor independence, gen_server bootstrap, and actor_state projection capture for Person + Service + Group. Step 2 fully closed (2a + 2b + 2c). Conformance 761/761. 146/146 across 10 Step-2-adjacent suites.

  • 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, :schema checking (string? (-> obj :name))). Manifest extended to 13 object-types / 34 total entries. genesis_parse.sh +7 cases (57/57). Hardcoded counts bumped in bootstrap_read.sh, bootstrap_load.sh, bootstrap_populate.sh, bootstrap_start.sh (66/66 across those four). bootstrap_build.sh 12/12 (bundle CID computed dynamically). Conformance 761/761 preserved. 211 / 211 across 12 Step-2-adjacent suites.

  • 2026-06-06 — Step 1b: gen_server multi-actor calls. nx_kernel exports add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1, bucket_for/1, with_projections_for/2 — each is a gen_server:call delegating to the pure-functional bucket API from 1a. Existing single-actor calls untouched. nx_kernel_multi.sh extended with 9 gen_server cases (26 total); 134 / 134 across 12 nx_kernel-adjacent + http suites. Conformance 761/761 preserved. Per-actor mailbox sharding noted as forward-looking — current single gen_server serialises publishes across actors, which is fine for Steps 1-3 (single-actor HTTP endpoints) and is naturally untangled by Step 4's per-actor routing.

  • 2026-06-06 — Step 1a: per-actor bucket refactor of nx_kernel. State shape now [{actors, [{Id, Bucket}, …]}, {next_actor_seq, N}]; added pure-functional multi-actor APIs (new/0, add_actor/4, has_actor/2, actors/1, publish/3, per-actor accessors, with_actor_projections/3). Legacy single-actor accessors preserved as bucket-0 lookups so every M1 test continues to pass via bootstrap:start/3new/3 → first-bucket read. Local has_keyed/find_keyed/set_keyed/set_bucket helpers cover the keyed-list ops since lists:keymember/keyfind aren't registered in this substrate. New test suite next/tests/nx_kernel_multi.sh 17/17; all M1 nx_kernel-adjacent suites green (bootstrap_start, nx_kernel_server, http_publish, smoke_app_pure, http_post_format, http_publish_fold, http_marshal). Erlang conformance 761/761 preserved.