next/kernel/delivery_worker.erl is the gen_server-per-peer
delivery queue per design §13.4. Step 8a lands the skeleton:
pure-functional state shape + enqueue / drain / deliver_one
helpers + backoff schedule + gen_server wrapper. No retry
timer wiring yet (Step 8b), no persist projection yet (8c),
no outbox dispatch wiring yet (8d), no httpc BIF yet (8e), no
live HTTP yet (8f).
State shape (pure):
[{peer, PeerId},
{pending, [Activity, ...]}, %% FIFO queue
{attempts, [{Cid, AttemptCount}]}, %% per-cid retry count
{dead_letter, [Activity, ...]},
{dispatch_fn, fun/1 | undefined}]
Pure-functional API:
new/1
pending/1, peer/1
enqueue_pure/3 — append to FIFO
drain_pure/1 — attempt every queued; returns
{NewState, DeliveredCids, RetryCids}
deliver_one_pure/2 — single dispatch via :dispatch_fn
Backoff schedule (§13.4): 30s / 5m / 30m / 6h / 24h then dead_letter
backoff_for/1 — attempt -> seconds | dead_letter
schedule_for/1 — attempt -> {retry_in, Sec} | dead_letter
gen_server (registered under peer-id atom):
start_link/1, start_link/2(PeerId, DispatchFn)
stop/1
enqueue/2 — sync call
flush/1 — drain + reply with {ok, Delivered, Retry}
pending_srv/1
set_dispatch_fn/2 — swap dispatch in flight
dispatch_fn is a caller-supplied 1-arity fun so tests can stub the
HTTP POST. Step 8f will plug in a closure over httpc:request/4
without touching the queue logic.
17/17 in next/tests/delivery_worker.sh covering:
- new/peer/pending base cases
- enqueue_pure FIFO append
- drain_pure no-dispatch -> retry, queue intact
- drain_pure ok dispatch -> queue empties + delivered list
- drain_pure failing dispatch -> queue intact + retry list
- deliver_one_pure {ok, Cid} and {error, _, no_dispatch_fn}
- backoff_for slot values match §13.4
- backoff_for >=6 returns dead_letter
- schedule_for wraps the slot or dead_letter
- gen_server start_link + enqueue + pending_srv
- gen_server flush with ok dispatch (delivered)
- gen_server flush with failing dispatch (queue kept)
- gen_server set_dispatch_fn in-flight swap
Conformance 761/761.
59 KiB
fed-sx Milestone 2 — Multi-actor + Federation
Real federation between two fed-sx instances. Per-actor state, signed
inbox delivery, Follow lifecycle, audience-resolving outbound queue, and
the rich verbs (Note, Announce, Endorse) needed for federated propagation.
Reference: plans/fed-sx-design.md (especially §9 identity, §13 federation,
§16 HTTP endpoints). Builds on Milestone 1 (see plans/fed-sx-milestone-1.md).
Goal
Two cooperating fed-sx instances A and B, each hosting one or more
actors, can:
- Discover each other's actors via webfinger + actor docs.
- Follow across instances (
Follow→Accept→ state). - Publish a
NoteonBand have it land in every follower'sactor-stateprojection onAvia signed inbox delivery. - Announce a peer's activity, propagating it to followers of the announcer.
- 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:PortAandlocalhost: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}]withActorBucket = [{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, legacypublish/2) continue to read from the first bucket — every M1 test passes viabootstrap:start/3→new/3→ first-bucket lookup.lists:keymember/keyfindnot in the substrate; localhas_keyed/find_keyed/set_keyed/set_buckethelpers handle the keyed-list ops.next/tests/nx_kernel_multi.sh17/17. - 1b — Multi-actor gen_server.
start_link/3still seeds bucket 0; new exportsadd_actor/3,publish_to/2(ActorId, Request),log_tip_for/1,actors/0,state_for/1,bucket_for/1,with_projections_for/2delegate to the pure- functional bucket APIs via freshhandle_callbranches. Existingpublish/1/log_tip/0/with_projections/1route 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.shextended 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 innext/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.shextended +7 cases (head form + :name + manifest membership), now 57/57. Bootstrap suite count assertions bumped (bootstrap_read.sh15/15,bootstrap_load.sh15/15,bootstrap_populate.sh14/14,bootstrap_start.sh10/10).bootstrap_build.sh12/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.erlwith state shape[{ActorId, Profile}, ...]whereProfileis 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(=:publishedseq). Duplicate Creates are no-overwrite.Update{Person|Service|Group, patch}: merges:patchinto the profile last-write-wins per key.Move: records:moved_toon the profile. Other activity types and non-actor object Creates pass through.fold_fn/0plugs intoprojection:start_link/3. Localfind_keyed/has_keyed/set_keyedhelpers (same gap as 1a — nolists:keyfind/keymemberin the substrate). 19 cases inactor_state_pure.sh.
- 2c —
nx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State)— adds an actor bucket and publishesCreate{Person|Service|Group}as the bucket's first activity in one call. Profile carries:type(defaults toperson),:name,:preferredUsername,:summary,:icon,:public_keys; the function builds the Create's:objectfrom the profile and the kernel-side AS from:public_keys. gen_server variantbootstrap_actor/3for live-kernel use; integration test inactor_lifecycle.shties 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:
- 3 —
actor_state.erlfold_updatenow routes patches throughapply_patch/3, which special-cases two rotation patch entries:{add_publicKey, KeyProplist}appends the key to:public_keys, defaulting:createdto the activity's:publishedif unset.{supersede, OldKeyId}marks the matching key with:superseded_at= activity's:published(idempotent: existing:superseded_atis preserved; unknown ids are no-ops). Other patch entries fall through to last-write-wins per key (preserving Step 2b semantics; verified by extraactor_state_pure.shcases). New exportskey_history/1(full list incl. superseded entries),active_keys_at/2(subset active at time T, mirroring envelope'sis_active_atsemantics — envelope keeps its predicate private, so a local copy lives here), andfind_key_by_id/2. Rotation-purpose schema gating per §9.6 ("rotation activity must itself be signed by an active key withrotate-keypurpose") is deferred to Step 5 (peer-sidestage_signaturewill plumb the purpose check through pipeline). 16 cases inkey_rotation.shcover rotation arithmetic,key_historypreservation, and liveenvelope:verify_signature/2round-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/1helper splits the/actors/suffix into{Id, SubPath}. GET dispatch routesoutbox/inbox/followers/followingsub-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 routesinboxto 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 M1actor_doc_response_forarm. 17 cases inhttp_multi_actor.sh. - 4b — Token → ActorId map. New
resolve_token/2reads:tokensfrom Cfg (proplist[{Token, ActorId}, ...]) and returns{ok, ActorId}on match. Falls back to the M1:publish_tokensingle-token field on miss (returns{ok, legacy}, route throughnx_kernel:publish/1to bucket 0 unchanged). Cfg with both fields::tokenswins for matched tokens;:publish_tokenonly consulted on:tokensmiss.handle_post_activitynow threads the resolvedActorReftopublish_if_kernel/3which dispatchespublish_to/2for explicit actor ids andpublish/1for thelegacyatom. No-kernel auth-only path unchanged. The dead M1expected_token/1helper is gone. 8 new cases inhttp_multi_actor.sh(25/25 total). - 4c —
http_server:route/3(Req, Cfg, Kernel)is sugar that folds the Kernel reference (typically the registerednx_kernelatom) 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:kerneland, when the actor exists in the kernel, renderstip: <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/catcharoundgen_server:calldeadlocks in this port's scheduler (probably the catch-frame mask defers reply delivery); the live handler does a barenx_kernel:log_tip_for/1- integer guard instead. 8 new cases in
http_multi_actor.sh(33/33 total).
- integer guard instead. 8 new cases in
- 4d — Per-actor outbox listing reads from the named
bucket's log entries via new
nx_kernel:log_state_for/1gen_server export.actor_outbox_full_response_for/5renders text / JSON / SX bodies with:tip,:page, and the page's:itemsCID list. Empty pages degrade to the 4c tip-only body to preserve back-compat with epochs 50-57.?page=Npagination parsed atroute/2time and threaded via Cfg as{request_query, Q};page_size/0returns 5 (proof of concept — production picks 20+). 8 new cases inhttp_multi_actor.sh(41/41 total). Substrate gotcha: named recursive funsfun F(...) -> ... F(...) endnot supported;binary:matches/2andlists:foreach/2not registered — tests prove behaviour viamatch_prefixsubstring 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_inboxlog bucket in nx_kernel.add_actor/4now opens a fresh inbox log (distinct base stub) for each new actor; the bucket carries[..., {actor_inbox, LogState}, ...]alongside the existing:logoutbox 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.sh14/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_envelope→stage_signature(PeerAS)→stage_replay(InboxLog)— halting on the first failure. New helperinbound_stages/2(PeerAS, InboxLog)exposes the stage list for callers that want to splice extra stages. Existingvalidate_inbound/1and the staticinbound_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 ininbox_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 shapeenvelope:verify_signature/2reads (proplist with:public_keys). Pure exports:new/0,lookup/2,store/3,evict/2,peers/1, and the load-bearinglookup_or_fetch/3(PeerId, FetchFn, State)that calls the caller-suppliedFetchFn :: (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/1accepts an initial state proplist for tests / fixtures. 19/19 inpeer_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. Newhandle_inbox_post/3orchestrates:kernel_has_actor→decode_activity(term_codec wire format) →resolve_peer_as(Cfg:peer_asmap >:peer_actorssrv >:peer_fetch_fnfallback) →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/1removed; the route/2 special case supersedes it. M1actor_inbox_post_response/0kept 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.sh11/11 covers happy path / shape / sig / replay / unknown-target / multi-message;inbox_peer_resolution.sh6/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:
- 6a —
follower_graph.erlErlang-fun stand-in for the genesisfollower-graph.sxprojection 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 infollower_graph.sh. Thefold_fn/02-arity fun plugs intoprojection:start_link/3exactly likedefine_registry:fold_fn/0andactor_state:fold_fn/0.
- 6b — Wire follower-graph fold to the inbox handler.
http_server.erlrun_inbox_pipelinenow callsbroadcast_to_inbox_projections/2after a successfulnx_kernel:append_inbox. Cfg may carry{inbox_projections, [Name, ...]}listing projection gen_servers; each gets the activity viaprojection: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 infollow_lifecycle.shcovering 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/3inhttp_server.erlfires after a successful inbox ingestion if Cfg carries{auto_accept_follows, true}AND the activity's:typeisfollow. The handler constructs anAccept{actor: target, object: OriginalFollow}request and routes it throughnx_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:projectionslist includes the same follower_graph projection the inbox uses, the Accept fold-converges the bilateral relationship —alice.followers = [bob]andbob.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 inauto_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:
- 7a —
delivery:delivery_set/2,3returns the audience-resolved deduplicated list of ActorId atoms for an outbound activity. Sources: explicit:toand:ccfields (atom or list of atoms / audience symbols), plus expansion offollowers(via follower_graph) andpublic(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 indelivery_set.shcovering 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 innext/kernel/delivery.erl(separate from outbox so Step 8's delivery-queue gen_server has a clean home). - 7b — Public audience expansion. v2 default:
publicexpands to the sender's followers (same asfollowers) 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 + followersin the same audience deduplicates because both symbols expand identically. 19/19 indelivery_set.sh(2 new cases- 1 case updated from the v2 placeholder behavior).
- 7c — Outbox-side integration.
outbox:publish/2now computes the delivery set after sign + log and stashes it in the Result proplist as{delivery_set, [ActorId, ...]}. Context's optional:follower_graphfield carries a follower_graph state forpublic/followersaudience expansion; absent -> empty graph (explicit:to/:ccrecipients still resolve). New helpercompute_delivery_set/3(Request, Signed, Context)andrecipients_envelope/2synthesise a minimal recipient envelope from Request's:to/:cc+ Signed's:actorsodelivery:delivery_set/3can process it unchanged (outbox:construct/4 doesn't carry:to/:ccthrough 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 inoutbox_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:
- 8a —
delivery_worker.erlskeleton: pure-functional state shape[{peer, _}, {pending, [_]}, {attempts, [{Cid, N}]}, {dead_letter, [_]}, {dispatch_fn, _}]plusenqueue_pure/3,drain_pure/1,deliver_one_pure/2and the backoff schedule (backoff_for/1,schedule_for/1) matching §13.4 (30s / 5m / 30m / 6h / 24h then dead-letter). gen_server wrapper withstart_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 indelivery_worker.sh. - 8b — Retry / backoff scheduler. Wire
schedule_for/1into a private retry loop:flush/1returns deliveries that failed; the worker schedules a self-cast via Erlangaftertimer 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.erlfold maps enqueue / delivered / failed events to the worker's persistent shape. - 8d —
outbox:publish/2dispatches each delivery-set entry to the matching worker. The worker is created lazily on first delivery to a peer. - 8e —
httpc:request/4BIF wrapper inlib/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/4with 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=Nreturns a paged response.- Backfill bodies wrap the original activities in
:backfilled trueso projections can decide whether to re-fold or skip.
Tests:
last-Nmode delivers exactly N most-recent activities.last-Tmode delivers everything published sincenow - T.fullmode delivers everything, page by page.nonemode 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-actorsprojection. 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
feedprojection optionally fetches the wrapped Note. - Endorse increments an endorsement counter on the target Activity.
- Verb registration is observable in the
define-registryprojection.
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:
- Each step's test suite passes (
bash next/tests/<step>.sh). - The federation smoke test passes (
bash next/tests/smoke_federate.sh). - Milestone 1 baseline preserved — the entire M1 test suite still passes (~560 assertions across 50 suites).
- Erlang-on-SX conformance — adding multi-actor + federation kernel
code in
next/kernel/*.erldoesn't break Phase 1-8 conformance (currently 761/761). - Restart durability — kill both instances mid-delivery, restart, queues resume, projections converge, no log corruption.
- Manual real Mastodon poke — point a Mastodon account at
https://next-A.rose-ash.com/actors/aliceand 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
DefineStorageentries. - 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
DefineProofentries. - 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.
-
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.
-
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. -
Auto-Accept policy on Follow. v2 ships open-world: every Follow is auto-accepted. Manual moderation (held in a
pendinglist, accepted via /admin/) is v3 with the operator dashboard. -
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.
-
Two-instance test harness. How do we start a pair of kernels in one bash test? Probably
bootstrap:start/3twice with different ActorIds + ports + base paths. Need to confirmnx_kernelcan 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). -
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.
-
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.
next/tests/http_server_tcp.sh0/5 — pre-existing regression introduced by78eae9ef(fed-sx-m1: 8b-bridge cleanup).lib/erlang/runtime.sx:1593still referenceser-http-resp-to-sxander-http-req-of-sxiner-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 liveer-request-dict-to-proplist/er-proplist-to-dicthelpers (which the cleanup commit's message claimed are already in use, but which the BIF body never picked up). Substrate work, belongs onloops/erlang. m2 work continues against the in-process HTTP layer (http_marshal.sh10/10,http_publish_fold.sh10/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.erlwith 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_fnfun — Step 8b layers the retry timer, Step 8c persists the queue, Step 8d wiresoutbox:publish/2to dispatch, Step 8e brings thehttpc:request/4BIF (substrate exception per briefing), Step 8f closes with live HTTP. 17/17 indelivery_worker.sh. Conformance 761/761. -
2026-06-07 — Step 7c (closes Step 7): outbox-side delivery_set integration.
outbox:publish/2computes the audience-resolved delivery set after sign + log and stashes it in the Result proplist as{delivery_set, [ActorId, ...]}. Newcompute_delivery_set/3(Request, Signed, Context)threads:follower_graphfrom Context through todelivery:delivery_set/3.recipients_envelope/2synthesises a minimal envelope from the Request's:to/:cc+ Signed's:actorso the existing delivery API works unchanged (envelope construct/4 doesn't carry the audience fields through). 17/17 inoutbox_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 asfollowers) — 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 indelivery_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::toand:ccfields (single atom or list, atoms or audience symbols), plus expansion offollowersvia the supplied follower_graph state.publicplaceholder 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 indelivery_set.sh. Conformance 761/761. Lives in its own module (not insideoutbox) 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/3inhttp_server.erlfires after successful inbox append + projection broadcast: if Cfg has{auto_accept_follows, true}and the activity is aFollow, construct[{type, accept}, {object, OriginalFollow}]and route throughnx_kernel:publish_to/2. The publish goes through the full outbox pipeline (construct + sign + log + projection broadcast), so when the target's outbox:projectionsshare 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 inauto_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/2inhttp_server.erlcasts every successfully-ingested activity into each:inbox_projectionsCfg entry viaprojection:async_fold/2. Fire-and-forget so the inbox handler doesn't block on fold processing. Empty / absent:inbox_projectionsis a no-op (back-compat with Steps 5d callers). 9/9 infollow_lifecycle.shcovering 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:objectpasses through.fold_fn/0is the standard 2-arity fun forprojection:start_link/3(same shape asactor_stateanddefine_registry). 18/18 infollower_graph.sh. Conformance 761/761. -
2026-06-06 — Step 5d: POST /actors//inbox real ingestion.
route/2now special-cases POST/actors/<id>/inboxnext to POST/activityso the body + full Cfg reach the newhandle_inbox_post/3handler. Flow:kernel_has_actor->decode_activity(term_codec wire format) ->resolve_peer_as(Cfg:peer_asmap >:peer_actorssrv >:peer_fetch_fnfallback) ->pipeline:validate_inbound/3->nx_kernel:append_inbox. Status codes 202 / 401 / 404 / 422 per design §16.1. v1 stubactor_post/1removed; M1actor_inbox_post_response/0kept for response shape composition. Projection broadcast on inbox success intentionally deferred to a follow-up.inbox.sh11/11 (basic ingestion: happy path / shape / sig / replay / unknown-target / multi-message);inbox_peer_resolution.sh6/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-bearinglookup_or_fetch/3(PeerId, FetchFn, State)entry: cache hit returns stored PeerAS unchanged; miss callsFetchFn(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 namepeer_actors;start_link/1accepts 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 inpeer_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)runsstage_envelope→stage_signature(PeerAS)→stage_replay(InboxLog)in order, halting on first failure. Newinbound_stages/2helper returns the 3-stage list. M1'svalidate_inbound/1+ staticinbound_stages/0(envelope-only) preserved for outbox- side callers. 14/14 ininbox_pipeline.shcovering 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.erladd_actor/4now opens a fresh log vialog:open/2with a distinctinbox_base_stub()for each new bucket and stores it as{actor_inbox, LogState}alongside the existing outbox:log. Pure exportsactor_inbox_state/2,actor_inbox_tip/2,append_to_actor_inbox/3+ gen_server exportsinbox_tip_for/1,inbox_state_for/1,append_inbox/2.log:append/2is(LogState, Activity) -> {ok, NewState, Seq}— noted for future iterations. Inbox / outbox tips are fully independent.next/tests/inbox_bucket.sh14/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/1gen_server export returns{ok, LogState}for an actor.actor_outbox_response_for/3now extracts{Tip, Entries}viakernel_actor_log_data/2, parses?page=Nfrom the Req's:queryfield (threaded through Cfg as{request_query, Q}), and renders a paged body. Text body addspage: 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/0is 5 for tests (production picks 20+). 8 new cases inhttp_multi_actor.sh(41/41 total). Conformance 761/761. 117/117 across 11 Step-4-adjacent suites. Gotcha noted: named recursive funsfun F(...) -> ... F(...) endfail with "fun-ref syntax not yet supported";binary:matches/2andlists:foreach/2aren'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:kerneland renderstip: <N>(in text / JSON / SX content-negotiated variants) when the actor exists; falls back to the 4a stub otherwise. Substrate quirk found:try/of/catcharoundgen_server:calldeadlocks 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 inhttp_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
:tokensproplist ([{Token, ActorId}, ...]) maps bearer tokens to per-actor publishers.handle_post_activitythreads the resolvedActorReftopublish_if_kernel/3which callsnx_kernel:publish_to/2for explicit actor ids andpublish/1for the back-compatlegacyatom (M1's:publish_tokensingle-token field still works as-is). When both fields are present,:tokenstakes precedence;:publish_tokenis the fallback on miss. Deadexpected_token/1helper removed. 8 new cases inhttp_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/1helper 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 existingactor_doc_response_for/2shape. POST/actors/<id>/inboxreturns 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_routeandhttp_post_formatregression suites unchanged (10/10 each). 17/17 inhttp_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.erlfold_updateroutes patches throughapply_patch/3which special-cases{add_publicKey, KeyProplist}(append + default:createdto activity's:published) and{supersede, OldKeyId}(mark:superseded_at, idempotent). Other patch entries still last-write-wins per key. New exportskey_history/1,active_keys_at/2,find_key_by_id/2give the projection-driven view thatenvelope:verify_signature/2consumes for time-aware lookup. Rotation-purpose schema gating (rotate-keypurpose check on the rotation activity itself) deferred to Step 5 (peer-side stage_signature).key_rotation.sh16/16 covers rotation arithmetic, key_history preservation, active_keys_at at T=pre, T=rotation, T=post, and liveenvelope:verify_signature/2round-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-endactor_lifecycle.sh. New pure-functional exportnx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State)adds an actor bucket viaadd_actor/4, derives the kernel AS proplist fromProfile's:public_keys, builds a Create envelope wrapping the profile's:type(defaultsperson) + field set, and callspublish/3. gen_server variantbootstrap_actor/3for live-kernel use plus a correspondinghandle_callbranch.actor_lifecycle.sh15/15 covers pure bootstrap (log_tip advances, Create-shape, dup detection), two-actor independence, gen_server bootstrap, andactor_stateprojection 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.erlwithfold/2over Create / Update / Move activities. Profile is a property list of:type / :name / :preferredUsername / :summary / :icon / :public_keys / :moved_to / :created. Create captures fields and:publishedas: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/0is a 2-arity Erlang fun forprojection:start_link/3(structural twin ofdefine_registry).next/tests/actor_state_pure.sh19/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 asnote.sx/sx-artifact.sx(:name,:doc,:schemachecking(string? (-> obj :name))). Manifest extended to 13 object-types / 34 total entries.genesis_parse.sh+7 cases (57/57). Hardcoded counts bumped inbootstrap_read.sh,bootstrap_load.sh,bootstrap_populate.sh,bootstrap_start.sh(66/66 across those four).bootstrap_build.sh12/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_kernelexportsadd_actor/3,publish_to/2,log_tip_for/1,actors/0,state_for/1,bucket_for/1,with_projections_for/2— each is agen_server:calldelegating to the pure-functional bucket API from 1a. Existing single-actor calls untouched.nx_kernel_multi.shextended 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 viabootstrap:start/3→new/3→ first-bucket read. Localhas_keyed/find_keyed/set_keyed/set_buckethelpers cover the keyed-list ops sincelists:keymember/keyfindaren't registered in this substrate. New test suitenext/tests/nx_kernel_multi.sh17/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.