Files
rose-ash/plans/fed-sx-milestone-2.md
giles 8ba3584556
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
fed-sx-m2: Step 8c — delivery-state projection + 14 tests
New next/kernel/delivery_state.erl folds delivery events into a
per-peer worker-shaped snapshot so the outbound queue survives
kernel restart.

Event proplist shapes:
  [{type, enqueued},      {peer, _}, {activity, _}]
  [{type, delivered},     {peer, _}, {cid, _}]
  [{type, failed},        {peer, _}, {cid, _}, {now, _}]
  [{type, dead_lettered}, {peer, _}, {cid, _}]

Projection state shape:
  [{PeerId, [{peer, _}, {pending, _}, {attempts, _},
             {next_retry, _}, {dead_letter, _}]}, ...]

Mirrors delivery_worker:new/1 (minus :dispatch_fn — that's the
live worker's concern) so a fresh gen_server can be hydrated
from the projection on restart.

Public API:
  new/0
  fold/2, fold_fn/0
  peer_state/2, peers/1
  pending/2, attempts/2, next_retry/2, dead_letter/2

The failed branch calls delivery_worker:backoff_for/1 directly,
so the projection and the live worker compute identical retry
slots and dead-letter thresholds. 6th failure -> dead-letter,
matching the worker.

14/14 in next/tests/delivery_state.sh covering:
  - new/0 -> []
  - enqueued appends to pending (FIFO)
  - two peers maintain independent queues
  - delivered clears matching pending entry
  - failed bumps :attempts and sets :next_retry
  - 6th failed -> dead-lettered (activity out of pending)
  - explicit dead_lettered event moves activity to dead_letter
  - peers/1 lists touched peers
  - peer_state {ok, _} | not_found
  - fold_fn/0 is fun/2 for projection:start_link
  - unknown event type passes through
  - delivered after failed clears retry state

delivery_worker.sh 17/17 unchanged, delivery_retry.sh 11/11
unchanged. Conformance preserved at 761/761.

The restart hydration helper (delivery_worker:state_from_proj/2
or similar) lands once 8b-timer can wire the live retry loop
(Blockers #3 — erlang:send_after substrate gap still open).
2026-06-07 02:37:53 +00:00

65 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-pure — Retry-time bookkeeping (pure-functional). State shape gains {next_retry, [{Cid, NextRetryAt}]} alongside the existing :attempts. New exports: record_failure_pure/3(Cid, Now, State), record_success_pure/2(Cid, State), next_due_pure/2(Now, State), attempts_for/2, next_retry_at/2, dead_letter_list/1. record_failure_pure bumps the attempt counter and computes Now + backoff_for(NewAttempts) as the next retry; on the 6th failure (backoff_for returns dead_letter) the matching activity moves from :pending to :dead_letter and the cid is cleared from :next_retry. record_success_pure clears both. next_due_pure returns cids whose retry time has passed. 11 cases in delivery_retry.sh.
  • 8b-timer — Erlang-side timer wiring (erlang:send_after self-cast or equivalent). Needs the same substrate primitive that gen_server uses for timeout returns. Defer behind substrate gap discovery for now — see Blockers.
  • 8c — Delivery-state projection (next/kernel/delivery_state.erl). Folds delivery events into per-peer worker-shaped snapshots so the outbound queue survives kernel restart. Event shapes: [{type, enqueued|delivered|failed|dead_lettered}, {peer, _}, {activity, _} | {cid, _}, {now, _}?]. State shape [{PeerId, WorkerProplist}, ...] mirrors delivery_worker:new/1's output so a fresh gen_server can be hydrated on restart. Public API: new/0, fold/2, fold_fn/0, peer_state/2, peers/1, per-field accessors (pending, attempts, next_retry, dead_letter). Uses delivery_worker:backoff_for/1 to decide dead-letter promotion on the 6th failure, so the projection and the live worker stay in lockstep. 14/14 in delivery_state.sh. The restart-hydration helper (delivery_worker:state_from_proj/2 or similar) lands when 8b-timer wires the live retry loop.
  • 8doutbox:publish/2 dispatches each delivery-set entry to the matching worker. New dispatch_deliveries/3 + enqueue_each/2 in outbox.erl walk the computed delivery_set and call delivery_worker:enqueue(PeerId, Activity) for each registered peer atom. Missing workers (no whereis) are silently skipped — lazy worker creation belongs to the kernel manager (Step 8d-mgr or later). Gated by Context field {dispatch_deliveries, true} so every M1 outbox caller stays back-compat (default off). 7/7 in delivery_dispatch.sh covering single-peer enqueue, two-peer fan-out, missing-worker skip, no-flag no-op, FIFO append across two publishes, empty delivery_set no-op.
  • 8ehttpc:request/4 BIF wrapper. Blocker: the briefing assumed a native http-request primitive existed in bin/sx_server.ml; on inspection there's only http-listen. The native http-CLIENT primitive belongs to loops/fed-prims (host primitives loop). Blockers entry below. m2 work continues with the in-process flow until the native lands.
  • 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.

  2. Native http-request (HTTP client) primitive missing — discovered during Step 8e prep. The fed-sx-m2 briefing ("Substrate available to you" §) claimed: "Native HTTP client primitive (registered in bin/sx_server.ml): http-request — exposed at the SX layer, currently native-only." On inspection bin/sx_server.ml only registers http-listen; there is no http-request registration. The HTTP client primitive belongs to loops/fed-prims (host primitives loop) per the one-primitive-loop-per-substrate convention. m2's Step 8e wrapper (httpc:request/4 BIF in lib/erlang/runtime.sx) can land in a 1-line follow-up once the native exists; m2 work continues with 8b-pure / 8c / 8d in the in-process flow.

  3. erlang:send_after-style timer primitive — discovered during Step 8b prep. The retry loop needs a way for the delivery_worker to wake itself up after backoff_for(N) seconds. Erlang's erlang:send_after/3 is the standard primitive; this port doesn't seem to register it (looked at how gen_server handles timeout returns — it's a message-loop self-cast that needs a delayed send). Belongs to loops/erlang (Erlang runtime substrate). m2 captures the retry semantics pure-functionally in 8b-pure so 8b-timer becomes a 1-shot wiring when the primitive lands.


Progress log

Newest first.

  • 2026-06-07 — Step 8c: delivery-state projection. New next/kernel/delivery_state.erl folds enqueue / delivered / failed / dead_lettered events into a per-peer worker-shaped snapshot. State shape mirrors delivery_worker:new/1's output so a fresh gen_server can be hydrated from the projection on kernel restart. The fail branch calls delivery_worker:backoff_for/1 directly, so the projection and the live worker compute identical retry slots / dead-letter thresholds. fold_fn/0 plugs into projection:start_link/3 just like actor_state and follower_graph. 14/14 in delivery_state.sh; delivery_worker.sh 17/17 + delivery_retry.sh 11/11 unchanged. Conformance preserved at 761/761. The hydration helper that loads a worker's pure state from the projection lands once 8b-timer can wire the live retry loop (Blockers #3 still open).

  • 2026-06-07 — Step 8b-pure: retry-time bookkeeping. delivery_worker state shape gains :next_retry proplist alongside :attempts. record_failure_pure/3(Cid, Now, State) bumps the per-cid counter and computes the next retry as Now + backoff_for(NewAttempts). On the 6th failure (backoff_for returns dead_letter) the matching activity moves from :pending to :dead_letter. record_success_pure/2 clears both :attempts and :next_retry for the cid. next_due_pure/2(Now, State) returns the cids whose retry time has passed (insertion order preserved). 11/11 in delivery_retry.sh. 8b-timer (real timer wiring via erlang:send_after-style primitive) and 8e (httpc:request/4 BIF) hit substrate gaps — Blockers entries added pointing to loops/erlang + loops/fed-prims. Conformance preserved at 761/761.

  • 2026-06-07 — Step 8d: outbox dispatches delivery_set to workers. outbox:publish/2 gained dispatch_deliveries/3 and enqueue_each/2: after log:append + projection broadcast, the resolved delivery_set is walked and each registered peer-id atom's delivery_worker:enqueue(PeerId, Activity) is called. Missing workers (no erlang:whereis) are silently skipped. Gated by Context's {dispatch_deliveries, true} — default off so every M1 outbox caller stays back-compat. 7/7 in delivery_dispatch.sh; outbox_publish.sh + delivery_worker.sh both still 17/17. Conformance preserved at 761/761 from the Step 8a baseline.

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