# 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 (`Follow` → `Accept` → 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:** - [x] **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/3` → `new/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. - [x] **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:** - [x] **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. - [x] **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`. - [x] **2c** — `nx_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:** - [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. --- ## Step 4 — Multi-actor HTTP routing Per-actor URLs per design §16.1: ``` GET /actors/ # actor doc GET /actors//outbox # OrderedCollection GET /actors//outbox?page=N # page POST /actors//inbox # peer delivery to this actor GET /actors//followers # follower list GET /actors//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:** - [x] **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//` return 404. Bare `/actors/` keeps the M1 `actor_doc_response_for` arm. 17 cases in `http_multi_actor.sh`. - [x] **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). - [x] **4c** — `http_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/3` → `dispatch/4`, `actor_get/2` → `actor_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: ` 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). - [x] **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. - [x] **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//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:** - [x] **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. - [x] **5b** — Inbound validation pipeline. New `pipeline:validate_inbound/3(Activity, PeerActorState, InboxLog)` runs the federation inbound stage list — `stage_envelope` → `stage_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. - [x] **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. - [x] **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_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 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: ```sx (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:** - [x] **6a** — `follower_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 `:object`s 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`. - [x] **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). - [x] **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:** - [x] **7a** — `delivery: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). - [x] **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). - [x] **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:** - [x] **8a** — `delivery_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. - [ ] **8d** — `outbox:publish/2` dispatches each delivery-set entry to the matching worker. The worker is created lazily on first delivery to a peer. - [ ] **8e** — `httpc: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//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@` returns the actor URL. - `GET /actors/` 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: }` | Propagate a peer's activity to followers | | `Endorse` | `{object: , kind: like|share}` | Cross-actor signaling | 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:** ```bash # 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":""}' # 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/.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//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_envelope` → `stage_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: \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: ` (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//...` 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//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/` 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/3` → `new/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.