Per design §13.2 the v2 Follow policy is open-world: every
successfully-ingested Follow triggers an Accept publish from the
target actor. Enabled per-Cfg via {auto_accept_follows, true} so
manual-moderation deployments can leave it off; default off.
http_server.erl run_inbox_pipeline gained maybe_auto_accept/3:
maybe_auto_accept(TargetAtom, Activity, Cfg) ->
case field(auto_accept_follows, Cfg) of
true ->
case envelope:get_field(type, Activity) of
{ok, follow} ->
Req = [{type, accept}, {object, Activity}],
nx_kernel:publish_to(TargetAtom, Req);
_ -> ok
end;
_ -> ok
end.
The publish routes through the full outbox pipeline (envelope
construct + HMAC sign + log append + outbox projection broadcast).
When the target's outbox :projections list shares the same
follower_graph projection that inbox broadcasts into, the bilateral
relationship fold-converges automatically — alice.followers = [bob]
and bob.following = [alice], both pending lists clear. No extra
test scaffolding needed because outbox:publish already runs the
broadcast hook from Step 7c.
Bad-sig and non-Follow ingestion short-circuit before the Accept
attempt (the validation pipeline rejects before run_inbox_pipeline's
ok branch fires).
9/9 in next/tests/auto_accept.sh:
- auto_accept on: alice's outbox tip advances to 1
- alice's outbox entry has :type = accept
- follower_graph converges to {alice.followers=[bob],
bob.following=[alice]}
- both sides' pending lists clear after the Accept fold
- auto_accept off (default): outbox stays empty; pending_inbound
still gets populated from the Step 6b inbox-projection path,
but alice.followers stays empty until human moderation acts
- non-Follow ingestion (Create{Note}) with auto_accept on: no
Accept published
- bad-sig Follow with auto_accept on: no Accept (sig short-circuit
in pipeline before maybe_auto_accept runs)
Step 6 fully closed (6a follower_graph projection, 6b inbox -> projection
broadcast wiring, 6c auto-Accept publish).
Conformance 761/761. 89/89 across 7 Step-6-adjacent suites
(inbox, inbox_peer_resolution, follower_graph, follow_lifecycle,
auto_accept, http_publish, nx_kernel_multi).
53 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:
outbox:delivery_set/2(Activity, KernelState) -> [InboxUrl].- Public expansion: every known peer instance's shared inbox (or every follower of the publishing actor — both modes supported).
- Followers expansion: follower-graph lookup.
- Self-delivery suppression (don't POST to your own inbox).
- Returns a list of
{PeerInstanceUrl, ActorId}tuples.
Tests:
- Activity with
:to: [bob]→ delivery set is bob's inbox. - Activity with
:to: [Followers]→ set is current followers' inboxes. - Activity with
:to: [Public]→ set is public reach. - Self-deliveries excluded.
- Empty audience → empty set.
Acceptance: bash next/tests/delivery_set.sh passes 12+ cases.
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:
delivery_worker.erl: gen_server per-peer queue withenqueue/2and a private retry loop.- Backoff schedule: 30s / 5m / 30m / 6h / 24h then dead-letter.
- Delivery state stored as a projection (
delivery-state) so it survives kernel restarts. outbox:publish/2augmented: afterlog:append, dispatch to the delivery worker for each delivery-set entry.- HTTP client: extend the existing native httpc primitive to carry signed envelope bytes + the right Content-Type.
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-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.