Files
rose-ash/plans/fed-sx-milestone-2.md
giles a23a2eb95a
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
fed-sx-m2: Step 4e — scope-boundary tick, no code change
POST /actors/<id>/inbox stays the 4a 202 'accepted' stub through
all of 4a-4d. The real inbound pipeline (peer sig verify + inbox-
bucket append + projection broadcast) is Step 5's whole topic, so
4e is closed as a deliberate scope boundary — no code change.

Step 4 fully closed (4a per-actor sub-paths, 4b token map,
4c route/3 + kernel access, 4d outbox listing + pagination, 4e
inbox-stays-stub).
2026-06-06 15:43:05 +00:00

42 KiB

fed-sx Milestone 2 — Multi-actor + Federation

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

Goal

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

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

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

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

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

Architecture summary

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

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

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

  • relay deferred to v3.

Build order

Twelve steps in dependency order.

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

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


Step 1 — Per-actor state buckets

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

Deliverables:

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

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


Step 2 — Actor lifecycle activities

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

Deliverables:

  • 2a — Genesis additions: DefineObject{Person} / DefineObject{Service} / DefineObject{Group} — three new SX files in next/genesis/object-types/ plus manifest entries (now 13 object-types total, 34 total genesis entries). Each defines :name, :doc, :schema (fn (obj) (string? (-> obj :name))). next/tests/genesis_parse.sh extended +7 cases (head form + :name + manifest membership), now 57/57. Bootstrap suite count assertions bumped (bootstrap_read.sh 15/15, bootstrap_load.sh 15/15, bootstrap_populate.sh 14/14, bootstrap_start.sh 10/10). bootstrap_build.sh 12/12 picks up the new bundle CID dynamically.
  • 2b — Actor-state projection fold (Erlang-fun stand-in, mirrors Step 5d-pure's define_registry). next/kernel/actor_state.erl with state shape [{ActorId, Profile}, ...] where Profile is a proplist with :type / :name / :preferredUsername / :summary / :icon / :public_keys / :moved_to / :created. Maps #{} aren't registered in the substrate, so the profile is a property list (same shape choice as the kernel's bucket / registry state). Folding rules:
    • Create{Person|Service|Group} (from a known :actor): captures profile fields + :created (=:published seq). Duplicate Creates are no-overwrite.
    • Update{Person|Service|Group, patch}: merges :patch into the profile last-write-wins per key.
    • Move: records :moved_to on the profile. Other activity types and non-actor object Creates pass through. fold_fn/0 plugs into projection:start_link/3. Local find_keyed/has_keyed/set_keyed helpers (same gap as 1a — no lists:keyfind/keymember in the substrate). 19 cases in actor_state_pure.sh.
  • 2cnx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State) — adds an actor bucket and publishes Create{Person|Service|Group} as the bucket's first activity in one call. Profile carries :type (defaults to person), :name, :preferredUsername, :summary, :icon, :public_keys; the function builds the Create's :object from the profile and the kernel-side AS from :public_keys. gen_server variant bootstrap_actor/3 for live-kernel use; integration test in actor_lifecycle.sh ties 2a artefacts, 2b projection, and 2c bootstrap together end-to-end (pure + gen_server + projection capture for all three actor types). 15/15.

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


Step 3 — Key rotation via Update + actor-state

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

Deliverables:

  • 3actor_state.erl fold_update now routes patches through apply_patch/3, which special-cases two rotation patch entries:
    • {add_publicKey, KeyProplist} appends the key to :public_keys, defaulting :created to the activity's :published if unset.
    • {supersede, OldKeyId} marks the matching key with :superseded_at = activity's :published (idempotent: existing :superseded_at is preserved; unknown ids are no-ops). Other patch entries fall through to last-write-wins per key (preserving Step 2b semantics; verified by extra actor_state_pure.sh cases). New exports key_history/1 (full list incl. superseded entries), active_keys_at/2 (subset active at time T, mirroring envelope's is_active_at semantics — envelope keeps its predicate private, so a local copy lives here), and find_key_by_id/2. Rotation-purpose schema gating per §9.6 ("rotation activity must itself be signed by an active key with rotate-key purpose") is deferred to Step 5 (peer-side stage_signature will plumb the purpose check through pipeline). 16 cases in key_rotation.sh cover rotation arithmetic, key_history preservation, and live envelope:verify_signature/2 round-trips for pre / post / mid rotation activities — including the negative case (post-rotation K1-signed activity returns {error, no_active_key}).

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


Step 4 — Multi-actor HTTP routing

Per-actor URLs per design §16.1:

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

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

Deliverables:

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

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


Step 5 — POST /inbox: signature verify + ingestion

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

Deliverables:

  • New per-actor log: actor_inbox. Same shape as outbox; activities marked :received_from => PeerActorId.
  • Inbound pipeline: stage_envelopestage_signature (against peer's actor-state, not local) → stage_replay.
  • Peer signature verification needs :public_keys from the peer's actor-state. v2 fetches the peer's actor doc lazily on first contact, caches it in a peer-actors projection. Stale-key invalidation deferred to v3.
  • HTTP handler: POST /actors/<id>/inbox returns 202 on accept, 401 on bad sig, 422 on replay or validation failure.

Tests:

  • POST /inbox with valid signed activity → 202, activity in inbox log.
  • POST /inbox with tampered envelope → 401.
  • POST /inbox with unknown actor target → 404.
  • POST /inbox with replay → 422.
  • Activity broadcast to receiving actor's projections.

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:

  • New activity-types (runtime via DefineActivity, ideally): Follow, Accept, Reject, Undo.
  • Follower-graph projection (Erlang-fun stand-in): tracks {ActorId => #{following => [PeerId], followers => [PeerId], pending_outbound => [PeerId], pending_inbound => [PeerId]}}.
  • Accept-handling fold logic: when A receives Accept{Follow A→B}, move B from pending_outbound to following.
  • Reciprocal: when B receives Follow A→B, automatically queue an outbound Accept (auto-accept policy; manual moderation v3).

Tests:

  • Follow → 202; sender's pending_outbound includes target.
  • Auto-Accept on receiving Follow; both sides' graphs update.
  • Reject leaves no following relationship.
  • Undo{Follow} removes the following.
  • Self-follow rejected.

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 with enqueue/2 and 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/2 augmented: after log: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=N returns a paged response.
  • Backfill bodies wrap the original activities in :backfilled true so projections can decide whether to re-fold or skip.

Tests:

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

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


Step 10 — Discovery

Per §13.7: webfinger plus actor doc fetch.

Deliverables:

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

Tests:

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

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


Step 11 — Rich verbs as runtime artifacts

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

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

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

Deliverables:

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

Tests:

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

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


Step 12 — Two-instance smoke test

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

Test outline:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Acceptance criteria for milestone 2

All of:

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

What lands when

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

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

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

What's deferred to milestone 3

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

Appendix A: open questions for milestone 2

Things still under-specified; resolve as work begins.

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

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

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

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

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

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

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


Blockers

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

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

Progress log

Newest first.

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

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

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

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

  • 2026-06-06 — Step 3 (closes Step 3): key rotation via Update. actor_state.erl fold_update routes patches through apply_patch/3 which special-cases {add_publicKey, KeyProplist} (append + default :created to activity's :published) and {supersede, OldKeyId} (mark :superseded_at, idempotent). Other patch entries still last-write-wins per key. New exports key_history/1, active_keys_at/2, find_key_by_id/2 give the projection-driven view that envelope:verify_signature/2 consumes for time-aware lookup. Rotation-purpose schema gating (rotate-key purpose check on the rotation activity itself) deferred to Step 5 (peer-side stage_signature). key_rotation.sh 16/16 covers rotation arithmetic, key_history preservation, active_keys_at at T=pre, T=rotation, T=post, and live envelope:verify_signature/2 round-trips for pre / post / cross scenarios including the negative-case post-rotation K1 sig. Conformance 761/761 preserved. 132/132 across 9 Step-3-adjacent suites (key_rotation, actor_state_pure, actor_lifecycle, envelope_sig, envelope_shape, envelope_canonical, nx_kernel_multi, bootstrap_start, smoke_app_pure).

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

  • 2026-06-06 — Step 2b: actor-state projection Erlang module. New next/kernel/actor_state.erl with fold/2 over Create / Update / Move activities. Profile is a property list of :type / :name / :preferredUsername / :summary / :icon / :public_keys / :moved_to / :created. Create captures fields and :published as :created; duplicate Create is no-overwrite; non-actor Creates and :actor- less envelopes pass through. Update last-write-wins per patch key. Move records :moved_to. fold_fn/0 is a 2-arity Erlang fun for projection:start_link/3 (structural twin of define_registry). next/tests/actor_state_pure.sh 19/19. Conformance 761/761. Step-2-adjacent no-regression gate 106/106 across 6 suites.

  • 2026-06-06 — Step 2a: genesis Person/Service/Group object- types. Three new SX files in next/genesis/object-types/ with the same shape as note.sx / sx-artifact.sx (:name, :doc, :schema checking (string? (-> obj :name))). Manifest extended to 13 object-types / 34 total entries. genesis_parse.sh +7 cases (57/57). Hardcoded counts bumped in bootstrap_read.sh, bootstrap_load.sh, bootstrap_populate.sh, bootstrap_start.sh (66/66 across those four). bootstrap_build.sh 12/12 (bundle CID computed dynamically). Conformance 761/761 preserved. 211 / 211 across 12 Step-2-adjacent suites.

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

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